From c3817327fa1064495b1b94832ab0fed77c648847 Mon Sep 17 00:00:00 2001 From: Interlap Date: Fri, 18 Sep 2026 20:09:16 +0200 Subject: [PATCH 1/4] hooks: preBuild and postBuild commands in builder.json, resolved per field and sent to the runners A top-level `hooks` object applies to every build; a profile's `hooks` overrides it field by field (a non-blank command replaces, a blank or absent one keeps the top-level; surrounding whitespace is dropped). ResolveProfile puts the result on BuildSettings.Hooks, with no profile too. Transport keeps GitHub under its ten-input limit: the `profile` input gains an optional `hooks` key, and ProfileInput now returns an object with an empty name when only top-level hooks are set, which the Resolve step already reads as "no profile". runner.sh gets BUILD_HOOKS (JSON of the commands set) from buildRemote only, since a simulator build never runs hooks, plus BUILD_PROFILE for BUILDER_PROFILE; both names are reserved for profile env. The build banner lists the hooks by name. --- internal/build/inputs_test.go | 55 ++++++++++++++++-- internal/build/progress.go | 3 + internal/build/remote.go | 12 +++- internal/build/remote_test.go | 10 +++- internal/config/profile.go | 69 +++++++++++++++++++++-- internal/config/profile_test.go | 98 ++++++++++++++++++++++++++++++++- internal/config/types.go | 15 +++++ pkg/config/config.go | 1 + 8 files changed, 247 insertions(+), 16 deletions(-) diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 6aeeb82..ea81127 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -144,27 +144,72 @@ func TestRemoteInputsMapping(t *testing.T) { s, _, _ = c.settings("preview", "", false) got = c.inputs("abcdef12", "ref", "sha", s) - if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" { + if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" || got["BUILD_PROFILE"] != "preview" { t.Fatalf("profile mapping: %v", got) } var env map[string]string if err := json.Unmarshal([]byte(got["BUILD_ENV"]), &env); err != nil || env["API_URL"] != "https://staging.example.com" { t.Fatalf("BUILD_ENV: %q %v", got["BUILD_ENV"], err) } + // The hooks are a build-only variable, added by buildRemote; inputs itself + // never sends them, since a simulator build shares it. + c.config.Hooks = &config.Hooks{PreBuild: "./scripts/prebuild.sh"} + s, _, _ = c.settings("preview", "", false) + if got = c.inputs("abcdef12", "ref", "sha", s); got["BUILD_HOOKS"] != "" { + t.Fatalf("shared inputs carry the hooks: %v", got) + } +} + +func TestGitHubInputsCarryHooks(t *testing.T) { + cfg := profiledConfig() + cfg.Hooks = &config.Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo top"} + p := cfg.Profiles["preview"] + p.Hooks = &config.Hooks{PostBuild: "./scripts/notify.sh"} + cfg.Profiles["preview"] = p + c := NewCoordinatorWithOutput(cfg, nil, io.Discard) + var profile struct { + Name string + Hooks map[string]string + } + // Top-level hooks travel without a profile, under an empty name. + s, _, _ := c.settings("", "", false) + got := c.buildInputs("abcdef12", "ref", s, "") + if err := json.Unmarshal([]byte(got["profile"]), &profile); err != nil || profile.Name != "" || profile.Hooks["preBuild"] != "./scripts/prebuild.sh" || profile.Hooks["postBuild"] != "echo top" { + t.Fatalf("profile input without a profile: %q %+v %v", got["profile"], profile, err) + } + s, _, _ = c.settings("preview", "", false) + got = c.buildInputs("abcdef12", "ref", s, "") + if err := json.Unmarshal([]byte(got["profile"]), &profile); err != nil || profile.Name != "preview" || profile.Hooks["preBuild"] != "./scripts/prebuild.sh" || profile.Hooks["postBuild"] != "./scripts/notify.sh" { + t.Fatalf("profile input with a profile: %q %+v %v", got["profile"], profile, err) + } + // The simulator workflow declares no profile input, hooks or not. + if _, ok := c.workflowInputs("abcdef12", "ref", s)["profile"]; ok { + t.Fatal("hooks reached the share inputs") + } } func TestSettingsPrinted(t *testing.T) { var out bytes.Buffer p := NewProgress(&out) p.Start("abcdef12") - p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") - for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { + p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc", Hooks: config.Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo done"}}, "github") + for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc", "Hooks: preBuild, postBuild"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q in:\n%s", want, out.String()) } } - if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") { - t.Fatal("env values should not be printed, only names") + if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") || strings.Contains(out.String(), "prebuild.sh") { + t.Fatal("env values and hook commands should not be printed, only names") + } + out.Reset() + p.Settings(&config.BuildSettings{Hooks: config.Hooks{PostBuild: "echo done"}}, "github") + if !strings.Contains(out.String(), "Hooks: postBuild\n") { + t.Errorf("only the hooks set are named:\n%s", out.String()) + } + out.Reset() + p.Settings(&config.BuildSettings{}, "github") + if strings.Contains(out.String(), "Hooks:") { + t.Errorf("no hooks, no line:\n%s", out.String()) } // Signed without a distribution is the legacy path with the unsuffixed diff --git a/internal/build/progress.go b/internal/build/progress.go index f4c736a..253dab1 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -102,6 +102,9 @@ func (p *Progress) Settings(s *config.BuildSettings, provider string) { if s.Distribution != "" { fmt.Fprintf(p.writer, " Distribution: %s\n", s.Distribution) } + if names := s.Hooks.Names(); len(names) > 0 { + fmt.Fprintf(p.writer, " Hooks: %s\n", strings.Join(names, ", ")) + } fmt.Fprintf(p.writer, "\n") } diff --git a/internal/build/remote.go b/internal/build/remote.go index 4f48493..f629169 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -62,8 +62,9 @@ func (c *Coordinator) remote(override string) (ci.Provider, config.CIConfig, err } // inputs are the variables runner.sh reads on Codemagic and Bitrise. The -// profile's env travels as one JSON object in BUILD_ENV, and DISTRIBUTION -// selects the signing set; both are only set when the profile provides them. +// profile's env travels as one JSON object in BUILD_ENV, DISTRIBUTION selects +// the signing set and BUILD_PROFILE names the profile for the hooks; each is +// only set when the profile provides it. func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) map[string]string { v := map[string]string{"BUILD_ID": buildID, "SNAPSHOT_REF": ref, "SNAPSHOT_SHA": sha, "IOS_PATH": c.config.IOS.Path, "SCHEME": s.Scheme, @@ -88,6 +89,9 @@ func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) if s.Distribution != "" { v["DISTRIBUTION"] = s.Distribution } + if s.Profile != "" { + v["BUILD_PROFILE"] = s.Profile + } return v } @@ -144,6 +148,10 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *co if opts.BuildNumber != "" { v["BUILDER_BUILD_NUMBER"] = opts.BuildNumber } + // The hooks are for IPA builds only; a simulator build never runs them. + if hooks := s.HooksJSON(); hooks != "" { + v["BUILD_HOOKS"] = hooks + } c.progress.Update(PhaseTriggering, "Triggering "+p.Name()+" build...") run, err := p.Start(ctx, ci.Request{Workflow: cfgCI.BuildWorkflow, Variables: v}) if err != nil { diff --git a/internal/build/remote_test.go b/internal/build/remote_test.go index fff8fc5..395df25 100644 --- a/internal/build/remote_test.go +++ b/internal/build/remote_test.go @@ -121,7 +121,8 @@ func TestRemoteSnapshotLifecycle(t *testing.T) { t.Fatal(err) } p := &fakeProvider{startErr: tt.startErr, cancelErr: tt.cancelErr, failPolling: tt.failPolling, payload: buf.Bytes(), statuses: []ci.Status{{Done: true, Success: true, State: "finished", Artifacts: []ci.Artifact{{Name: "app.ipa"}}}}} - cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, Codemagic: config.CIConfig{AppID: "app", Branch: "main"}} + cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, Codemagic: config.CIConfig{AppID: "app", Branch: "main"}, + Hooks: &config.Hooks{PostBuild: "echo done"}} c := NewCoordinatorWithProvider(cfg, p, io.Discard) if tt.name == "artifact failure after completion" { c.provider = &failedArtifactProvider{p} @@ -162,6 +163,10 @@ func TestRemoteSnapshotLifecycle(t *testing.T) { var result *BuildResult if strings.HasSuffix(tt.name, "share") { _, err = c.Share(context.Background(), ShareOptions{}) + // A simulator build never runs the hooks, so it is not told about them. + if v := p.request.Variables; v["BUILD_HOOKS"] != "" { + t.Errorf("hooks sent to a simulator build: %v", v) + } } else { result, err = c.Build(context.Background(), &BuildOptions{OutputDir: filepath.Join(dir, "dist"), BuildNumber: "1.2.3+42"}) // Codemagic defines BUILD_NUMBER itself; the stamp must travel @@ -169,6 +174,9 @@ func TestRemoteSnapshotLifecycle(t *testing.T) { if v := p.request.Variables; v["BUILDER_BUILD_NUMBER"] != "1.2.3+42" || v["BUILD_NUMBER"] != "" { t.Errorf("build number variables: %v", v) } + if v := p.request.Variables; v["BUILD_HOOKS"] != `{"postBuild":"echo done"}` { + t.Errorf("hooks variable: %v", v) + } } if tt.name == "success" || tt.name == "transient poll recovers" { if err != nil || result == nil { diff --git a/internal/config/profile.go b/internal/config/profile.go index 86c42ec..8e4880a 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -24,6 +24,43 @@ type BuildSettings struct { // Distribution is the profile's distribution, canonical (internal is // ad-hoc); empty for unsigned builds and the legacy path. Distribution string + // Hooks are the top-level hooks with the profile's applied field by + // field, trimmed; they apply with no profile too. + Hooks Hooks +} + +// Empty reports whether no hook is set. +func (h Hooks) Empty() bool { return h.PreBuild == "" && h.PostBuild == "" } + +// Names lists the hooks that are set, in the order they run. +func (h Hooks) Names() []string { + var names []string + if h.PreBuild != "" { + names = append(names, "preBuild") + } + if h.PostBuild != "" { + names = append(names, "postBuild") + } + return names +} + +// resolveHooks lays the profile's hooks over the top-level ones: a non-blank +// command replaces the one below it, a blank one keeps it. Surrounding +// whitespace is dropped, so a hook that is only whitespace is absent. +func resolveHooks(top, profile *Hooks) Hooks { + var h Hooks + for _, src := range []*Hooks{top, profile} { + if src == nil { + continue + } + if v := strings.TrimSpace(src.PreBuild); v != "" { + h.PreBuild = v + } + if v := strings.TrimSpace(src.PostBuild); v != "" { + h.PostBuild = v + } + } + return h } // reservedEnv names the variables the runners, the shell and the CI services @@ -31,7 +68,7 @@ type BuildSettings struct { // runner.sh, replace a provider secret, since the env is exported first. var reservedEnv = []string{ "BUILD_ID", "SNAPSHOT_REF", "SNAPSHOT_SHA", "IOS_PATH", "SCHEME", "CONFIGURATION", - "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "DISTRIBUTION", + "USE_SIGNING", "FLUTTER_VERSION", "JDK_VERSION", "BUILD_ENV", "BUILD_HOOKS", "BUILD_PROFILE", "DISTRIBUTION", "SIGNING_SET", "SIGNING_SET_USED", "DURATION", "PROJECT_TYPE", "EXPORT_METHOD", "BUILD_NUMBER", "CODE_SIGN_IDENTITY", "DEVELOPMENT_TEAM", "PROVISIONING_PROFILE_NAME", "PROFILE_BUNDLE_ID", "EXTENSION_PROFILES", "MOBAI_API_KEY", @@ -81,6 +118,7 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { Scheme: c.IOS.Scheme, Signing: c.IOS.Signing, Provider: c.Provider, + Hooks: resolveHooks(c.Hooks, nil), } source := "profile" if name == "" { @@ -128,6 +166,7 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { if len(p.Env) > 0 { s.Env = p.Env } + s.Hooks = resolveHooks(c.Hooks, p.Hooks) return s, nil } @@ -142,21 +181,39 @@ func (s *BuildSettings) EnvJSON() string { return string(data) } -// ProfileInput encodes name, env and distribution as the single `profile` -// dispatch input, keeping the workflow under GitHub's limit of ten inputs. It -// is empty when no profile is selected, so older workflow files still work. +// ProfileInput encodes name, env, distribution and hooks as the single +// `profile` dispatch input, keeping the workflow under GitHub's limit of ten +// inputs. It is empty when no profile is selected and no hook is set, so older +// workflow files still work; top-level hooks without a profile travel with an +// empty name, which the workflow reads as "no profile". func (s *BuildSettings) ProfileInput() string { - if s.Profile == "" { + if s.Profile == "" && s.Hooks.Empty() { return "" } env := s.Env if env == nil { env = map[string]string{} } + var hooks *Hooks + if !s.Hooks.Empty() { + h := s.Hooks + hooks = &h + } data, _ := json.Marshal(struct { Name string `json:"name"` Env map[string]string `json:"env"` Distribution string `json:"distribution"` - }{s.Profile, env, s.Distribution}) + Hooks *Hooks `json:"hooks,omitempty"` + }{s.Profile, env, s.Distribution, hooks}) + return string(data) +} + +// HooksJSON encodes the resolved hooks as a JSON object of the commands that +// are set, for runner.sh's BUILD_HOOKS; empty when there is none. +func (s *BuildSettings) HooksJSON() string { + if s.Hooks.Empty() { + return "" + } + data, _ := json.Marshal(s.Hooks) // two strings cannot fail to marshal return string(data) } diff --git a/internal/config/profile_test.go b/internal/config/profile_test.go index d025923..47302f5 100644 --- a/internal/config/profile_test.go +++ b/internal/config/profile_test.go @@ -160,7 +160,101 @@ func TestProfileEncodings(t *testing.T) { t.Fatal("profile input must be a single line") } noEnv := BuildSettings{Profile: "development"} - if got := noEnv.ProfileInput(); !strings.Contains(got, `"env":{}`) { - t.Fatalf("env should be an object even when empty: %s", got) + if got := noEnv.ProfileInput(); !strings.Contains(got, `"env":{}`) || strings.Contains(got, "hooks") { + t.Fatalf("env should be an object even when empty, and no hooks key without hooks: %s", got) + } +} + +func TestResolveHooks(t *testing.T) { + cfg := profileConfig() + cfg.Hooks = &Hooks{PreBuild: " ./scripts/prebuild.sh\n", PostBuild: "echo top"} + cfg.Profiles["notify"] = Profile{Distribution: "store", Hooks: &Hooks{PostBuild: "echo one\necho two"}} + cfg.Profiles["blank"] = Profile{Hooks: &Hooks{PreBuild: " \n\t", PostBuild: ""}} + cfg.Profiles["clear"] = Profile{} + for _, tt := range []struct { + name, profile string + want Hooks + }{ + // Top-level hooks apply with no profile, trimmed. + {"no profile", "", Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo top"}}, + // A profile replaces the fields it sets and keeps the others. + {"per-field override", "notify", Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo one\necho two"}}, + // Whitespace-only is absent, so the top-level hook stays. + {"blank keeps the top level", "blank", Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo top"}}, + {"no hooks on the profile", "clear", Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo top"}}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := cfg.ResolveProfile(tt.profile) + if err != nil { + t.Fatal(err) + } + if got.Hooks != tt.want { + t.Fatalf("hooks = %+v, want %+v", got.Hooks, tt.want) + } + }) + } + // Without any hooks nothing is set, and a blank top-level hook is none. + cfg.Hooks = &Hooks{PreBuild: " "} + if got, err := cfg.ResolveProfile("clear"); err != nil || !got.Hooks.Empty() || got.Hooks.Names() != nil { + t.Fatalf("blank top-level hooks: %+v %v", got.Hooks, err) + } + if names := (Hooks{PreBuild: "a", PostBuild: "b"}).Names(); strings.Join(names, ",") != "preBuild,postBuild" { + t.Fatalf("Names = %v", names) + } + if names := (Hooks{PostBuild: "b"}).Names(); strings.Join(names, ",") != "postBuild" { + t.Fatalf("Names = %v", names) + } +} + +func TestHooksEncodings(t *testing.T) { + // Top-level hooks without a profile still travel: an empty name with the + // hooks, which the workflow reads as "no profile". + s := BuildSettings{Hooks: Hooks{PreBuild: "echo \"pre\"\nexit 0"}} + var input struct { + Name string + Env map[string]string + Distribution string + Hooks map[string]string + } + if err := json.Unmarshal([]byte(s.ProfileInput()), &input); err != nil || input.Name != "" || input.Env == nil || input.Hooks["preBuild"] != s.Hooks.PreBuild || len(input.Hooks) != 1 { + t.Fatalf("profile input without a profile: %q %+v %v", s.ProfileInput(), input, err) + } + if strings.Contains(s.ProfileInput(), "\n") || strings.Contains(s.ProfileInput(), "postBuild") { + t.Fatalf("profile input must be one line and carry only the hooks set: %q", s.ProfileInput()) + } + if got := s.HooksJSON(); got != `{"preBuild":"echo \"pre\"\nexit 0"}` { + t.Fatalf("HooksJSON = %q", got) + } + // With a profile the hooks ride along with name, env and distribution. + s = BuildSettings{Profile: "store", Distribution: "store", Hooks: Hooks{PreBuild: "a", PostBuild: "b"}} + if err := json.Unmarshal([]byte(s.ProfileInput()), &input); err != nil || input.Name != "store" || input.Distribution != "store" || input.Hooks["preBuild"] != "a" || input.Hooks["postBuild"] != "b" { + t.Fatalf("profile input with hooks: %q %v", s.ProfileInput(), err) + } + if got := s.HooksJSON(); got != `{"preBuild":"a","postBuild":"b"}` { + t.Fatalf("HooksJSON = %q", got) + } + if (&BuildSettings{Profile: "store"}).HooksJSON() != "" { + t.Fatal("no hooks must produce no BUILD_HOOKS") + } +} + +func TestHooksJSONRoundTrip(t *testing.T) { + raw := `{"project":"App","github":{"owner":"o","repo":"r"}, + "hooks":{"preBuild":"./scripts/prebuild.sh"}, + "profiles":{"store":{"distribution":"store","hooks":{"postBuild":"./scripts/notify.sh"}}}}` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatal(err) + } + if cfg.Hooks == nil || cfg.Hooks.PreBuild != "./scripts/prebuild.sh" || cfg.Profiles["store"].Hooks == nil || cfg.Profiles["store"].Hooks.PostBuild != "./scripts/notify.sh" { + t.Fatalf("parsed %+v", cfg) + } + out, err := json.Marshal(&Config{Project: "App", Profiles: map[string]Profile{"p": {Distribution: "store"}}}) + if err != nil || strings.Contains(string(out), "hooks") { + t.Fatalf("absent hooks should be omitted: %s %v", out, err) + } + out, err = json.Marshal(Hooks{PostBuild: "x"}) + if err != nil || string(out) != `{"postBuild":"x"}` { + t.Fatalf("hooks encoding: %s %v", out, err) } } diff --git a/internal/config/types.go b/internal/config/types.go index 2d38ec0..ada0a82 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -21,12 +21,24 @@ type Config struct { // Signing records where `signing setup` put the key, .p12 and profile; // nil when it was the working directory. Signing *SigningConfig `json:"signing,omitempty"` + // Hooks run on the runner around every build; a profile's hooks override + // them field by field. + Hooks *Hooks `json:"hooks,omitempty"` // DefaultProfile is used when a command is run without --profile. Tag-triggered // runs have no flags, so it is also the only way they can select a profile. DefaultProfile string `json:"defaultProfile,omitempty"` Profiles map[string]Profile `json:"profiles,omitempty"` } +// Hooks are shell commands the runner executes around the build, from the +// repository root with the profile's env exported, as `bash -eo pipefail -c`, +// so a multi-line script stops at its first failure. A failing hook fails the +// job. Simulator builds (ios share) never run them. +type Hooks struct { + PreBuild string `json:"preBuild,omitempty"` // after every dependency install, before the build number is applied and the archive starts + PostBuild string `json:"postBuild,omitempty"` // after the IPA exists (BUILDER_IPA), before it is uploaded as an artifact +} + // SigningConfig is where the signing material lives on this machine. type SigningConfig struct { // Dir is the --out-dir of the last automatic `signing setup`, as given @@ -46,6 +58,9 @@ type Profile struct { // ad-hoc or internal, store, enterprise; empty is unsigned): it selects the // signing set and the type the provisioning profile in it must have. Distribution string `json:"distribution,omitempty"` + // Hooks override the top-level hooks field by field: a non-empty command + // replaces the top-level one, an absent or blank one keeps it. + Hooks *Hooks `json:"hooks,omitempty"` } // CIConfig identifies an app already connected to the project's GitHub repository. diff --git a/pkg/config/config.go b/pkg/config/config.go index 408e102..a6fa05f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,6 +23,7 @@ type ( WatchConfig = config.WatchConfig ReactNativeConfig = config.ReactNativeConfig MobAIConfig = config.MobAIConfig + Hooks = config.Hooks ValidationError = config.ValidationError Manager = config.Manager ) From c37f473eace253cc15e51b4f5b3d70283f3de9fb Mon Sep 17 00:00:00 2001 From: Interlap Date: Fri, 18 Sep 2026 20:09:17 +0200 Subject: [PATCH 2/4] workflow: run the build hooks on GitHub, Codemagic and Bitrise run_hook lives in a `# >>> build hooks` block that is byte-identical in ios-build.yml (Build IPA step) and runner.sh: it runs the command from the checkout root as `bash -eo pipefail -c` with the profile env and BUILDER_{HOOK,BUILD_ID,PROFILE,CONFIGURATION,DISTRIBUTION,IOS_PATH, PROJECT_TYPE,BUILD_NUMBER}, BUILDER_IPA for postBuild, a foldable group on GitHub, and fails the job naming the hook and its exit code. preBuild runs after pod install and project selection, before the build number, the signing settings and the archive; in runner.sh at the top of build_ipa rather than prepare, which the simulator mode shares. postBuild runs once build/.ipa exists, before the artifact upload. The Resolve parameters step takes the hooks from the `profile` input on a dispatch and merges them from builder.json in jq on a tag build, then exports BUILDER_HOOK_PRE_BUILD/POST_BUILD through the same base64 heredoc as the env, only when non-blank. runner.sh reads BUILD_HOOKS, validates it once jq is there, and exports PROJECT_TYPE for the hooks. Tests: TestBuildHooksBlockIdentical, TestRunHook (cwd, variables, IPA for postBuild only, first-failure stop, pipefail, exit code in the message), TestBuildHooksPlacement, the Resolve step for tag and dispatch with overrides, blanks and non-object hooks, and the end-to-end runner.sh build with both hooks recording what they saw. --- internal/workflow/hooks_test.go | 249 ++++++++++++++++++++++ internal/workflow/profile_test.go | 52 ++++- internal/workflow/providers_test.go | 22 +- internal/workflow/templates/ios-build.yml | 67 +++++- internal/workflow/templates/runner.sh | 50 ++++- 5 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 internal/workflow/hooks_test.go diff --git a/internal/workflow/hooks_test.go b/internal/workflow/hooks_test.go new file mode 100644 index 0000000..3645ecb --- /dev/null +++ b/internal/workflow/hooks_test.go @@ -0,0 +1,249 @@ +package workflow + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const ( + hooksBlockStart = "# >>> build hooks" + hooksBlockEnd = "# <<< build hooks\n" +) + +// hooksBlock returns the shared run_hook shell block embedded in script. +func hooksBlock(t *testing.T, label, script string) string { + t.Helper() + script = strings.ReplaceAll(script, "\r\n", "\n") + start := strings.Index(script, hooksBlockStart) + if start < 0 { + t.Fatalf("%s: no build hooks block", label) + } + end := strings.Index(script[start:], hooksBlockEnd) + if end < 0 { + t.Fatalf("%s: build hooks block is not terminated", label) + } + return script[start : start+end+len(hooksBlockEnd)] +} + +// hooksBlocks collects the block from runner.sh and from the Build IPA step of +// ios-build.yml, the only two places that run hooks: the simulator workflow +// and `runner.sh simulator` never do. +func hooksBlocks(t *testing.T) map[string]string { + t.Helper() + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + all := steps(t, "ios-build.yml") + build := all[indexOfStep(t, all, "Build IPA")] + found := map[string]string{ + "runner.sh": hooksBlock(t, "runner.sh", string(data)), + "ios-build.yml Build IPA": hooksBlock(t, "ios-build.yml Build IPA", build.Run), + } + for _, step := range all { + if step.Name != "Build IPA" && strings.Contains(step.Run, hooksBlockStart) { + t.Errorf("ios-build.yml step %q embeds the hooks block; only Build IPA runs hooks", step.Name) + } + } + share, err := GetTemplate("ios-share.yml") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(share), "run_hook") || strings.Contains(strings.ToLower(string(share)), "hook") { + t.Error("ios-share.yml mentions hooks; simulator builds never run them") + } + return found +} + +// TestBuildHooksBlockIdentical keeps the GitHub workflow and runner.sh running +// hooks the same way: same working directory, same variables, same failure. +func TestBuildHooksBlockIdentical(t *testing.T) { + blocks := hooksBlocks(t) + want := blocks["runner.sh"] + for label, got := range blocks { + if got != want { + t.Errorf("%s has drifted from runner.sh:\n%s\n--- runner.sh:\n%s", label, got, want) + } + } + if !strings.Contains(want, "run_hook()") || !strings.Contains(want, "bash -eo pipefail -c") { + t.Fatalf("the shared block is missing run_hook or its shell invocation:\n%s", want) + } +} + +// TestRunHook runs the shared block the way both runners call it: the hook +// command is a multi-line script that records where it ran and what it saw. +func TestRunHook(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + block := hooksBlocks(t)["runner.sh"] + // Both runners define fail; the stub keeps the message and the exit. + script := "set -eu\nfail() { echo \"FAIL: $*\" >&2; exit 1; }\n" + block + `run_hook "$@" +echo "after $1" +` + workspace := t.TempDir() + iosDir := filepath.Join(workspace, "ios") + if err := os.MkdirAll(iosDir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "hook.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + env := map[string]string{ + "GITHUB_WORKSPACE": workspace, "BUILD_ID": "abcdef12", "BUILD_PROFILE": "store", "CONFIGURATION": "Release", + "DISTRIBUTION": "store", "IOS_PATH": "ios", "PROJECT_TYPE": "flutter", "BUILD_NUMBER": "1.2.3+42", "API_URL": "https://staging.example.com", + } + run := func(t *testing.T, cwd string, extra map[string]string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command("bash", append([]string{path}, args...)...) + cmd.Dir = cwd + cmd.Env = append(os.Environ(), "GITHUB_ACTIONS=", "BUILDER_WORKSPACE=") + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + for k, v := range extra { + cmd.Env = append(cmd.Env, k+"="+v) + } + out, err := cmd.CombinedOutput() + return string(out), err + } + record := `set -u +pwd > "$HOOK_LOG" +for v in BUILDER_HOOK BUILDER_BUILD_ID BUILDER_PROFILE BUILDER_CONFIGURATION BUILDER_DISTRIBUTION BUILDER_IOS_PATH BUILDER_PROJECT_TYPE BUILDER_BUILD_NUMBER API_URL; do + printf '%s=%s\n' "$v" "${!v}" >> "$HOOK_LOG" +done +printf 'BUILDER_IPA=%s\n' "${BUILDER_IPA:-unset}" >> "$HOOK_LOG" +echo "recorded" +` + read := func(t *testing.T, log string) map[string]string { + t.Helper() + data, err := os.ReadFile(log) + if err != nil { + t.Fatal("the hook did not run:", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + got := map[string]string{"pwd": lines[0]} + for _, line := range lines[1:] { + k, v, _ := strings.Cut(line, "=") + got[k] = v + } + return got + } + + t.Run("preBuild from the iOS directory", func(t *testing.T) { + log := filepath.Join(t.TempDir(), "hook.log") + // The runners are inside ios_path when preBuild runs; the hook is not. + out, err := run(t, iosDir, map[string]string{"HOOK_LOG": log}, "preBuild", record) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + got := read(t, log) + want := map[string]string{"BUILDER_HOOK": "preBuild", "BUILDER_BUILD_ID": "abcdef12", "BUILDER_PROFILE": "store", + "BUILDER_CONFIGURATION": "Release", "BUILDER_DISTRIBUTION": "store", "BUILDER_IOS_PATH": "ios", "BUILDER_PROJECT_TYPE": "flutter", + "BUILDER_BUILD_NUMBER": "1.2.3+42", "API_URL": "https://staging.example.com", "BUILDER_IPA": "unset"} + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %q, want %q", k, got[k], v) + } + } + if real, _ := filepath.EvalSymlinks(workspace); got["pwd"] != workspace && got["pwd"] != real { + t.Errorf("hook ran in %q, want the workspace %q", got["pwd"], workspace) + } + if !strings.Contains(out, "== preBuild hook ==") || !strings.Contains(out, "recorded") || !strings.Contains(out, "after preBuild") || strings.Contains(out, "::group::") { + t.Errorf("output:\n%s", out) + } + }) + + t.Run("postBuild gets the IPA and the GitHub group", func(t *testing.T) { + log := filepath.Join(t.TempDir(), "hook.log") + ipa := filepath.Join(workspace, "build", "abcdef12.ipa") + out, err := run(t, workspace, map[string]string{"HOOK_LOG": log, "GITHUB_ACTIONS": "true"}, "postBuild", record, ipa) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + got := read(t, log) + if got["BUILDER_HOOK"] != "postBuild" || got["BUILDER_IPA"] != ipa { + t.Errorf("postBuild variables: %v", got) + } + if !strings.Contains(out, "::group::postBuild hook\n") || !strings.Contains(out, "::endgroup::\n") || strings.Contains(out, "== postBuild") { + t.Errorf("output:\n%s", out) + } + }) + + t.Run("an empty command is no hook", func(t *testing.T) { + out, err := run(t, workspace, nil, "preBuild", "") + if err != nil || strings.Contains(out, "hook") { + t.Fatalf("%v\n%s", err, out) + } + }) + + t.Run("a multi-line script stops at the first failure", func(t *testing.T) { + marker := filepath.Join(t.TempDir(), "reached") + out, err := run(t, workspace, nil, "preBuild", "echo first\ntrue | true\nexit 3\ntouch '"+marker+"'\n") + if err == nil { + t.Fatalf("a failing hook passed:\n%s", out) + } + if !strings.Contains(out, "FAIL: preBuild hook failed (exit 3)") || strings.Contains(out, "after preBuild") { + t.Errorf("output:\n%s", out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Error("the script went on after the failing line") + } + // pipefail: a failing producer fails the pipeline. + out, err = run(t, workspace, nil, "postBuild", "false | cat\necho unreachable\n", "x.ipa") + if err == nil || !strings.Contains(out, "postBuild hook failed (exit 1)") || strings.Contains(out, "unreachable") { + t.Errorf("pipefail: %v\n%s", err, out) + } + }) +} + +// TestBuildHooksPlacement pins where the workflow runs the hooks: preBuild +// after the Pods install and before anything is stamped or archived, postBuild +// after the IPA exists and before the artifact upload. +func TestBuildHooksPlacement(t *testing.T) { + all := steps(t, "ios-build.yml") + build := all[indexOfStep(t, all, "Build IPA")] + run := build.Run + pre := strings.Index(run, `run_hook preBuild "${BUILDER_HOOK_PRE_BUILD:-}"`) + post := strings.Index(run, `run_hook postBuild "${BUILDER_HOOK_POST_BUILD:-}" "$GITHUB_WORKSPACE/build/$BUILD_ID.ipa"`) + pods := strings.Index(run, "pod install") + stamp := strings.Index(run, "apply_build_number\n") + if pre < 0 || post < 0 || pods < 0 || stamp < 0 { + t.Fatalf("hook calls or landmarks missing: pre=%d post=%d pods=%d stamp=%d", pre, post, pods, stamp) + } + if pre < pods || stamp < pre { + t.Errorf("preBuild must run after pod install and before the build number is applied: pods=%d pre=%d stamp=%d", pods, pre, stamp) + } + if last := strings.LastIndex(run, "Created "); post < last { + t.Errorf("postBuild must run after the IPA is created: created=%d post=%d", last, post) + } + if _, ok := build.Env["BUILD_PROFILE"]; !ok { + t.Error("Build IPA lacks BUILD_PROFILE in its env, so BUILDER_PROFILE would be empty") + } + if indexOfStep(t, all, "Upload IPA artifact") != indexOfStep(t, all, "Build IPA")+1 { + t.Error("the artifact upload should directly follow the build step that runs postBuild") + } + // runner.sh: preBuild opens build_ipa (not prepare, which the simulator + // mode shares) and postBuild closes it. + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + fn := shellFunc(t, string(data), "build_ipa") + if !strings.Contains(fn, `run_hook preBuild "$(hook_command preBuild)"`) || !strings.Contains(fn, `run_hook postBuild "$(hook_command postBuild)" "$BUILDER_WORKSPACE/build/$BUILD_ID.ipa"`) { + t.Errorf("build_ipa does not run both hooks:\n%s", fn) + } + if strings.Index(fn, "run_hook preBuild") > strings.Index(fn, "install_signing") { + t.Error("runner.sh preBuild must run before signing is installed") + } + for _, name := range []string{"prepare", "build_simulator"} { + if strings.Contains(shellFunc(t, string(data), name), "run_hook") { + t.Errorf("%s runs a hook; simulator builds must not", name) + } + } +} diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 979dd08..16a84ab 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -100,10 +100,12 @@ const profiledBuilderJSON = `{ "project": "App", "github": {"owner": "o", "repo": "r"}, "ios": {"path": "ios", "scheme": "Top", "signing": true, "configuration": "Debug"}, "defaultProfile": "preview", + "hooks": {"preBuild": "./scripts/prebuild.sh", "postBuild": "echo top"}, "profiles": { "preview": {"distribution": "internal", "env": {"API_URL": "https://staging.example.com", "NOTES": "line one\n__BUILDER_ENV__\nline \"two\""}}, - "dev": {"distribution": "development"}, + "dev": {"distribution": "development", + "hooks": {"preBuild": " \n", "postBuild": "echo \"dev\"\n__BUILDER_HOOK__\nexit 0"}}, "debug-store": {"configuration": "Debug", "distribution": "store"}, "unsigned": {"scheme": "Other"} } @@ -137,6 +139,29 @@ func TestResolveParametersApplyProfiles(t *testing.T) { if r.env["API_URL"] != "https://staging.example.com" || r.env["NOTES"] != "line one\n__BUILDER_ENV__\nline \"two\"" { t.Fatalf("env not exported verbatim: %q\n%s", r.env, r.log) } + // A profile without hooks of its own runs the top-level ones. + if r.env["BUILDER_HOOK_PRE_BUILD"] != "./scripts/prebuild.sh" || r.env["BUILDER_HOOK_POST_BUILD"] != "echo top" || !strings.Contains(r.log, "hooks: preBuild, postBuild") { + t.Fatalf("top-level hooks not exported: %q\n%s", r.env, r.log) + } + }) + + t.Run("tag build lays the profile's hooks over the top-level ones", func(t *testing.T) { + withDefault := strings.Replace(profiledBuilderJSON, `"defaultProfile": "preview"`, `"defaultProfile": "dev"`, 1) + r := runResolve(t, build, withDefault, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil { + t.Fatalf("%v\n%s", r.err, r.log) + } + // dev's blank preBuild keeps the top-level one; its multi-line + // postBuild replaces the top-level one verbatim. + if r.env["BUILDER_HOOK_PRE_BUILD"] != "./scripts/prebuild.sh" || r.env["BUILDER_HOOK_POST_BUILD"] != "echo \"dev\"\n__BUILDER_HOOK__\nexit 0" { + t.Fatalf("hooks: %q\n%s", r.env, r.log) + } + // Top-level hooks apply with no profile at all, and blank ones are none. + plain := `{"hooks": {"preBuild": "make generate", "postBuild": " "}}` + r = runResolve(t, build, plain, map[string]string{"GITHUB_EVENT_NAME": "push"}) + if r.err != nil || r.outputs["profile"] != "" || r.env["BUILDER_HOOK_PRE_BUILD"] != "make generate" || len(r.env) != 1 || !strings.Contains(r.log, "hooks: preBuild\n") { + t.Fatalf("hooks without a profile: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) + } }) t.Run("tag build derives configuration and signing from the distribution", func(t *testing.T) { @@ -183,12 +208,34 @@ func TestResolveParametersApplyProfiles(t *testing.T) { r.outputs["profile"] != "production" || r.outputs["distribution"] != "store" || r.outputs["signing_set"] != "STORE" || r.env["API_URL"] != "https://api.example.com" { t.Fatalf("outputs %v env %v\n%s", r.outputs, r.env, r.log) } + // The input carries no hooks, so builder.json's on disk must not run. + if len(r.env) != 1 { + t.Fatalf("a dispatch read hooks from builder.json: %v\n%s", r.env, r.log) + } // Without a selected profile the input carries its default. env["IN_PROFILE"] = "{}" r = runResolve(t, build, "", env) if r.err != nil || r.outputs["profile"] != "" || r.outputs["distribution"] != "" || r.outputs["signing_set"] != "" || len(r.env) != 0 { t.Fatalf("default profile input: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) } + // The hooks come from the input, multi-line and quoted values intact. + env["IN_PROFILE"] = `{"name":"production","env":{},"distribution":"store","hooks":{"preBuild":"echo \"pre\"\n__BUILDER_HOOK__\nexit 0","postBuild":"./scripts/notify.sh"}}` + r = runResolve(t, build, "", env) + if r.err != nil || r.env["BUILDER_HOOK_PRE_BUILD"] != "echo \"pre\"\n__BUILDER_HOOK__\nexit 0" || r.env["BUILDER_HOOK_POST_BUILD"] != "./scripts/notify.sh" || !strings.Contains(r.log, "hooks: preBuild, postBuild") { + t.Fatalf("hooks from the input: %v %v\n%s", r.err, r.env, r.log) + } + // Top-level hooks with no profile travel under an empty name. + env["IN_PROFILE"] = `{"name":"","env":{},"distribution":"","hooks":{"postBuild":"echo done"}}` + r = runResolve(t, build, "", env) + if r.err != nil || r.outputs["profile"] != "" || r.outputs["signing_set"] != "" || r.env["BUILDER_HOOK_POST_BUILD"] != "echo done" || len(r.env) != 1 { + t.Fatalf("hooks without a profile: %v %v %v\n%s", r.err, r.outputs, r.env, r.log) + } + // Blank commands and other keys are not hooks. + env["IN_PROFILE"] = `{"name":"p","hooks":{"preBuild":" \n","postBuild":null,"postbuild":"typo"}}` + r = runResolve(t, build, "", env) + if r.err != nil || len(r.env) != 0 || strings.Contains(r.log, "hooks:") { + t.Fatalf("blank hooks: %v %v\n%s", r.err, r.env, r.log) + } }) t.Run("bad profiles fail the job", func(t *testing.T) { @@ -202,6 +249,9 @@ func TestResolveParametersApplyProfiles(t *testing.T) { "bad env name": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":{"A B":"x"}}`}}, "env not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":"A=x"}`}}, "profile not JSON": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `preview`}}, + "hooks not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","hooks":"./scripts/prebuild.sh"}`}}, + "top-level hooks string": {`{"hooks": "./scripts/prebuild.sh"}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, + "profile hooks a list": {`{"defaultProfile": "p", "profiles": {"p": {"hooks": ["./scripts/prebuild.sh"]}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, } { if r := runResolve(t, build, tt.json, tt.env); r.err == nil { t.Errorf("%s accepted:\n%s", name, r.log) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index bbf3d4d..3883a89 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -178,14 +178,34 @@ fi // Codemagic's own build counter, which a plain build must not stamp // on the app. buildEnv := `{"API_URL":"https://staging.example.com","NOTES":"line one\nline \"two\""}` + // The hooks arrive merged as one JSON object; each records the + // BUILDER_* variables it sees, preBuild before any xcodebuild call + // and postBuild with the IPA. + hookLog := filepath.Join(dir, "hook.log") + buildHooks := `{"preBuild":"printf 'pre|%s|%s|%s|%s|%s\\n' \"$BUILDER_HOOK\" \"$BUILDER_PROJECT_TYPE\" \"$BUILDER_PROFILE\" \"$API_URL\" \"$(ls \"$SCHEME_LOG\" 2>/dev/null)\" >> \"$HOOK_LOG\"\ntest \"$PWD\" = \"$BUILDER_WORKSPACE\"",` + + `"postBuild":"printf 'post|%s|%s|%s\\n' \"$BUILDER_HOOK\" \"$BUILDER_IPA\" \"$BUILDER_BUILD_NUMBER\" >> \"$HOOK_LOG\"\ntest -f \"$BUILDER_IPA\""}` cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "SNAPSHOT_REF="+ref, "SNAPSHOT_SHA="+sha, "BUILD_ID=abcdef12", "IOS_PATH=.", "USE_SIGNING=false", "CONFIGURATION=Debug", "BUILD_NUMBER=17", "SCHEME="+scheme, "SCHEME_LOG="+filepath.Join(dir, "scheme.log"), "BUILDER_CI_DIR="+filepath.Join(dir, "state"), - "BUILD_ENV="+buildEnv, "DISTRIBUTION=ad-hoc", "ENV_LOG="+filepath.Join(dir, "env.log")) + "BUILD_ENV="+buildEnv, "DISTRIBUTION=ad-hoc", "ENV_LOG="+filepath.Join(dir, "env.log"), + "BUILD_PROFILE=preview", "BUILD_HOOKS="+buildHooks, "HOOK_LOG="+hookLog, + // A GitHub-hosted test run must not point the hooks at its own checkout. + "GITHUB_WORKSPACE=", "GITHUB_ACTIONS=") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("runner: %s %v", out, err) } if data, err := os.ReadFile(filepath.Join(dir, "env.log")); err != nil || string(data) != "https://staging.example.com|line one\nline \"two\"|ad-hoc" { t.Fatalf("profile env did not reach the build: %q %v", data, err) } + // preBuild saw no scheme.log yet (xcodebuild had not run), the + // profile's env and name; postBuild got the IPA and no build number. + // BUILDER_WORKSPACE is $(pwd) after the checkout, so macOS's /var symlink is resolved. + realClone, err := filepath.EvalSymlinks(clone) + if err != nil { + t.Fatal(err) + } + wantHooks := "pre|preBuild|native|preview|https://staging.example.com|\npost|postBuild|" + filepath.Join(realClone, "build", "abcdef12.ipa") + "|\n" + if data, err := os.ReadFile(hookLog); err != nil || string(data) != wantHooks { + t.Fatalf("hooks did not run as expected: %q %v, want %q", data, err, wantHooks) + } if _, err := os.Stat(filepath.Join(dir, "scheme.log.stamped")); !os.IsNotExist(err) { t.Fatal("the provider's BUILD_NUMBER was stamped on a plain build") } diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index d78a18e..aed1533 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -53,7 +53,7 @@ on: # One input for the profile fields that are not inputs of their own, so # the workflow stays under the ten-input limit of workflow_dispatch. profile: - description: 'Selected builder.json profile as JSON: {"name": "...", "env": {...}, "distribution": "..."}' + description: 'Selected builder.json profile as JSON: {"name": "...", "env": {...}, "distribution": "...", "hooks": {"preBuild": "...", "postBuild": "..."}}; an empty name with hooks means the top-level hooks without a profile' required: false type: string default: '{}' @@ -138,11 +138,17 @@ jobs: param build_number "$IN_BUILD_NUMBER" '""' '' # The rest of the profile: name, distribution (selects the signing - # set) and env, exported from here on so dependency installs see it. + # set), env, exported from here on so dependency installs see it, and + # the build hooks. A tag build merges the hooks the way the CLI does: + # the profile's non-blank commands over the top-level ones. if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then PROFILE_JSON="$IN_PROFILE" elif [ -f builder.json ]; then - PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {}), distribution: (.profiles[$p].distribution // "")}' builder.json) + if [ "$(jq -r --arg p "$PROFILE" '[.hooks // {}, .profiles[$p].hooks // {}] | all(type == "object")' builder.json)" != "true" ]; then + echo "::error::hooks in builder.json must be an object of preBuild and postBuild commands"; exit 1 + fi + PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {}), distribution: (.profiles[$p].distribution // ""), + hooks: ((.hooks // {}) + (.profiles[$p].hooks // {} | with_entries(select(.value | type == "string" and test("\\S")))))}' builder.json) fi [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") @@ -190,6 +196,30 @@ jobs: } >> "$GITHUB_ENV" echo "env: $name" done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") + # The hooks go the same way, as BUILDER_HOOK_PRE_BUILD and + # BUILDER_HOOK_POST_BUILD, for the build step to run; a blank + # command is no hook. Only ever from here, never from builder.json + # on a dispatch, so the CLI is the source of truth. + if [ "$(jq -r '.hooks // {} | type' <<< "$PROFILE_JSON")" != "object" ]; then + echo "::error::the profile's hooks must be a JSON object of preBuild and postBuild commands"; exit 1 + fi + HOOKS="" + while IFS=' ' read -r hook encoded; do + case "$hook" in + preBuild) name=BUILDER_HOOK_PRE_BUILD ;; + postBuild) name=BUILDER_HOOK_POST_BUILD ;; + *) continue ;; + esac + delim="BUILDER_HOOK_${RANDOM}${RANDOM}${RANDOM}" + { + echo "$name<<$delim" + printf '%s' "$encoded" | base64 --decode + echo + echo "$delim" + } >> "$GITHUB_ENV" + HOOKS="${HOOKS:+$HOOKS, }$hook" + done < <(jq -r '.hooks // {} | to_entries[] | select(.value | type == "string" and test("\\S")) | "\(.key) \(.value | @base64)"' <<< "$PROFILE_JSON") + [ -z "$HOOKS" ] || echo "hooks: $HOOKS" - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -868,6 +898,7 @@ jobs: CONFIGURATION: ${{ steps.params.outputs.configuration }} BUILD_NUMBER: ${{ steps.params.outputs.build_number }} DISTRIBUTION: ${{ steps.params.outputs.distribution }} + BUILD_PROFILE: ${{ steps.params.outputs.profile }} run: | set -e fail() { echo "::error::$*" >&2; exit 1; } @@ -1005,6 +1036,29 @@ jobs: PY } + # >>> build hooks (keep identical across ios-build.yml and runner.sh) + # Runs the builder.json hook $1 (preBuild or postBuild) with the command + # $2 from the repository root, as `bash -eo pipefail -c` so a multi-line + # script stops at its first failure; a failing hook fails the job. The + # profile's env is already exported, the BUILDER_* variables describe + # the build, and $3 is the IPA a postBuild hook gets as BUILDER_IPA. + run_hook() { + local hook="$1" command="$2" ipa="${3:-}" status=0 + [ -n "$command" ] || return 0 + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::group::$hook hook"; else echo "== $hook hook =="; fi + ( + cd "${GITHUB_WORKSPACE:-$BUILDER_WORKSPACE}" || exit 1 + export BUILDER_HOOK="$hook" BUILDER_BUILD_ID="${BUILD_ID:-}" BUILDER_PROFILE="${BUILD_PROFILE:-}" + export BUILDER_CONFIGURATION="${CONFIGURATION:-}" BUILDER_DISTRIBUTION="${DISTRIBUTION:-}" + export BUILDER_IOS_PATH="${IOS_PATH:-}" BUILDER_PROJECT_TYPE="${PROJECT_TYPE:-}" BUILDER_BUILD_NUMBER="${BUILD_NUMBER:-}" + if [ -n "$ipa" ]; then export BUILDER_IPA="$ipa"; fi + exec bash -eo pipefail -c "$command" + ) || status=$? + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::endgroup::"; fi + [ "$status" -eq 0 ] || fail "$hook hook failed (exit $status)" + } + # <<< build hooks + # Navigate to iOS project cd "$IOS_PATH" @@ -1069,6 +1123,10 @@ jobs: fi fi + # Every dependency is in place (pub get, node_modules, expo prebuild, + # XcodeGen, pod install); nothing has been built or stamped yet. + run_hook preBuild "${BUILDER_HOOK_PRE_BUILD:-}" + # Use custom DerivedData path for caching DERIVED_DATA_PATH="${GITHUB_WORKSPACE}/DerivedData" @@ -1271,6 +1329,9 @@ jobs: echo "Created unsigned IPA: build/$BUILD_ID.ipa" fi + # The IPA exists and is not yet an artifact. + run_hook postBuild "${BUILDER_HOOK_POST_BUILD:-}" "$GITHUB_WORKSPACE/build/$BUILD_ID.ipa" + - name: Upload IPA artifact uses: actions/upload-artifact@v7 with: diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index caa1b5c..1543f9f 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -8,8 +8,10 @@ mode="${1:-build}" export IOS_PATH="${IOS_PATH:-.}" SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Debug}" export USE_SIGNING="${USE_SIGNING:-false}" JDK_VERSION="${JDK_VERSION:-17}" # From the selected builder.json profile: DISTRIBUTION (canonical, so internal -# arrives as ad-hoc) picks the signing set, BUILD_ENV is a JSON object prepare() exports. -export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" +# arrives as ad-hoc) picks the signing set, BUILD_ENV is a JSON object prepare() exports, +# BUILD_PROFILE is the profile's name and BUILD_HOOKS a JSON object of the preBuild +# and postBuild commands (top level and profile already merged) build_ipa runs. +export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" BUILD_PROFILE="${BUILD_PROFILE:-}" BUILD_HOOKS="${BUILD_HOOKS:-}" # Codemagic sets BUILD_NUMBER itself (its build counter), so the CLI sends # the CFBundleVersion to stamp as BUILDER_BUILD_NUMBER; empty means none. export BUILD_NUMBER="${BUILDER_BUILD_NUMBER:-}" @@ -30,6 +32,42 @@ export_build_env() { done < <(jq -r 'to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$BUILD_ENV") } +# Checked once jq is there, before anything is built; hook_command reads the +# commands out for build_ipa (empty when unset or blank). +check_build_hooks() { + [ -n "$BUILD_HOOKS" ] || return 0 + if [ "$(jq -r 'type' <<< "$BUILD_HOOKS" 2>/dev/null)" != "object" ]; then echo "BUILD_HOOKS must be a JSON object of preBuild and postBuild commands" >&2; exit 1; fi + echo "hooks: $(jq -r '[to_entries[] | select(.value | type == "string" and test("\\S")) | .key] | join(", ")' <<< "$BUILD_HOOKS")" +} + +hook_command() { + [ -n "$BUILD_HOOKS" ] || return 0 + jq -r --arg hook "$1" '.[$hook] // empty | select(type == "string" and test("\\S"))' <<< "$BUILD_HOOKS" +} + +# >>> build hooks (keep identical across ios-build.yml and runner.sh) +# Runs the builder.json hook $1 (preBuild or postBuild) with the command +# $2 from the repository root, as `bash -eo pipefail -c` so a multi-line +# script stops at its first failure; a failing hook fails the job. The +# profile's env is already exported, the BUILDER_* variables describe +# the build, and $3 is the IPA a postBuild hook gets as BUILDER_IPA. +run_hook() { + local hook="$1" command="$2" ipa="${3:-}" status=0 + [ -n "$command" ] || return 0 + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::group::$hook hook"; else echo "== $hook hook =="; fi + ( + cd "${GITHUB_WORKSPACE:-$BUILDER_WORKSPACE}" || exit 1 + export BUILDER_HOOK="$hook" BUILDER_BUILD_ID="${BUILD_ID:-}" BUILDER_PROFILE="${BUILD_PROFILE:-}" + export BUILDER_CONFIGURATION="${CONFIGURATION:-}" BUILDER_DISTRIBUTION="${DISTRIBUTION:-}" + export BUILDER_IOS_PATH="${IOS_PATH:-}" BUILDER_PROJECT_TYPE="${PROJECT_TYPE:-}" BUILDER_BUILD_NUMBER="${BUILD_NUMBER:-}" + if [ -n "$ipa" ]; then export BUILDER_IPA="$ipa"; fi + exec bash -eo pipefail -c "$command" + ) || status=$? + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::endgroup::"; fi + [ "$status" -eq 0 ] || fail "$hook hook failed (exit $status)" +} +# <<< build hooks + snapshot_checkout() { case "${SNAPSHOT_REF:-}" in refs/ios-builder/jobs/*) ;; @@ -209,8 +247,10 @@ prepare() { project_type=native fi echo "Project type: $project_type" + export PROJECT_TYPE="$project_type" # for the hooks, as the GitHub workflow names it if ! command -v jq >/dev/null; then brew install jq; fi export_build_env + check_build_hooks # Match the GitHub workflows' committed xcconfig-template convention. find . -path ./DerivedData -prune -o -type f \ @@ -609,6 +649,10 @@ apply_build_number() { } build_ipa() { + # Every dependency is in place (prepare ran pub get, node_modules, expo + # prebuild, XcodeGen, pod install); nothing is signed, stamped or built yet. + # Not in prepare: a simulator build shares that and never runs the hooks. + run_hook preBuild "$(hook_command preBuild)" # Before the compile, so a profile/configuration mismatch fails without # waiting for the archive. if [ "$USE_SIGNING" = true ]; then install_signing; fi @@ -656,6 +700,8 @@ build_ipa() { rm -rf "$payload_dir" fi echo "Created build/$BUILD_ID.ipa" + # The IPA exists and the provider has not collected it yet. + run_hook postBuild "$(hook_command postBuild)" "$BUILDER_WORKSPACE/build/$BUILD_ID.ipa" } build_simulator() { From 22d653f4f7664e2d25d7887fa5a9f535dfd901d5 Mon Sep 17 00:00:00 2001 From: Interlap Date: Fri, 18 Sep 2026 20:09:17 +0200 Subject: [PATCH 3/4] docs: build hooks README gets a "Build hooks" section after the profiles (when each runs, working directory, BUILDER_* variables, failure semantics, per-profile override, not for ios share) and `hooks` in the configuration example; CLAUDE.md gets the example, the profile field, and a Build Hooks pattern covering the transport, placement and the shared block's identity test. --- CLAUDE.md | 32 +++++++++++++++++++++++++------- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 555aafd..bd2d197 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,12 +246,27 @@ internal/ - **Build Profiles**: `profiles.` overrides `ios.configuration`/`ios.scheme`/`provider` and adds `env` and `distribution` (`config.ResolveProfile`: `--profile`, else `defaultProfile`, else top level unchanged). A profile signs iff it has a `distribution`; `ios.signing` is only the no-profile path -- **Profile Transport**: the `profile` dispatch input is one JSON object (`{"name","env","distribution"}`) - to stay under the ten-input limit and is sent only when a profile is selected, since an older - workflow rejects unknown inputs (`triggerError`). `runner.sh` reads `BUILD_ENV` and `DISTRIBUTION` +- **Profile Transport**: the `profile` dispatch input is one JSON object (`{"name","env","distribution","hooks"}`) + to stay under the ten-input limit and is sent only when a profile is selected or a hook is set, since an + older workflow rejects unknown inputs (`triggerError`). `runner.sh` reads `BUILD_ENV`, `DISTRIBUTION`, + `BUILD_PROFILE` and `BUILD_HOOKS` - **Profile Env**: entries are base64 per key/value on the runner and the `$GITHUB_ENV` heredoc uses a random delimiter; names must match `^[A-Za-z_][A-Za-z0-9_]*$` and not hit `reservedEnv`/ `reservedEnvPrefixes` (`internal/config/profile.go`), which must track what the runners read +- **Build Hooks**: `hooks.preBuild`/`hooks.postBuild` (top level, overridden per field by a profile's; + `config.resolveHooks`, whitespace-only = absent) run on the runner only, from the checkout root as + `bash -eo pipefail -c`, with the profile env plus `BUILDER_{HOOK,BUILD_ID,PROFILE,CONFIGURATION, + DISTRIBUTION,IOS_PATH,PROJECT_TYPE,BUILD_NUMBER}` and `BUILDER_IPA` for postBuild. Transport: the + `profile` dispatch input gains a `hooks` key (`ProfileInput` sends `{"name":"",...,"hooks":…}` for + top-level hooks without a profile), a tag build merges them from builder.json in jq, and runner.sh + gets `BUILD_HOOKS` (JSON) and `BUILD_PROFILE` (both reserved). The Resolve step exports them as + `BUILDER_HOOK_PRE_BUILD`/`BUILDER_HOOK_POST_BUILD` through the same base64 heredoc as the env. + Placement: preBuild after `pod install`/project selection and before `apply_build_number`, + signing settings and the archive (`Build IPA` step; top of `build_ipa` in runner.sh, not `prepare`, + which the simulator mode shares); postBuild after `Created … IPA`, before the artifact upload. The + `run_hook` function lives in a `# >>> build hooks` … `# <<< build hooks` block that is byte-identical + in `ios-build.yml` and `runner.sh` (`TestBuildHooksBlockIdentical`; `TestRunHook` executes it) and + only uses `fail` plus variables both runners set. `ios share`/`runner.sh simulator` never run hooks. - **Signing Sets**: one trio per distribution, `IOS_{CERTIFICATE,CERTIFICATE_PASSWORD,PROVISIONING_PROFILE}_` (DEVELOPMENT, AD_HOC, STORE, ENTERPRISE); the unsuffixed names serve only the legacy no-profile path. The table lives in `config.SigningSet` and the shell `signing_set` (both templates) and must agree @@ -436,10 +451,12 @@ internal/ "github": { "owner": "username", "repo": "my-ios-app" }, "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" }, "defaultProfile": "development", + "hooks": { "preBuild": "./scripts/prebuild.sh", "postBuild": "./scripts/upload-symbols.sh" }, "profiles": { "development": { "distribution": "development" }, "preview": { "distribution": "internal", "env": { "API_URL": "https://staging.example.com" } }, - "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" } + "production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic", + "hooks": { "postBuild": "./scripts/notify.sh" } } } } ``` @@ -451,7 +468,8 @@ the first IPA exists. `signing.dir` is the last automatic `signing setup`'s `--o A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the only signing field, omitted = unsigned), `configuration` (else Debug for development, Release -otherwise), `scheme`, `provider`, `env`. `runner`/`submit` are planned on `config.Profile`, not read. +otherwise), `scheme`, `provider`, `env`, `hooks` (`preBuild`/`postBuild`, each overriding the +top-level one when non-blank). `runner`/`submit` are planned on `config.Profile`, not read. ## Workflow Features @@ -470,8 +488,8 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`): `use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the tagged tree, applying `defaultProfile` (a tag cannot pick a profile per run; `build_number` is always empty there); every later step reads `steps.params.outputs.*`, never `inputs.*`. The same - step exports the profile's `env` to `$GITHUB_ENV` and outputs `profile`, `distribution` and - `signing_set`. The job deletes + step exports the profile's `env` and the merged hooks (`BUILDER_HOOK_PRE_BUILD`/`_POST_BUILD`) to + `$GITHUB_ENV` and outputs `profile`, `distribution` and `signing_set`. The job deletes the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an unfiltered `on: push` also fires on these tags. - Runs on `macos-latest` diff --git a/README.md b/README.md index cc24fe8..a6c394c 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,10 @@ machine-readable output and never prompts, so agents and CI jobs can drive them. "development": { "distribution": "development" }, "store": { "distribution": "store" } }, + "hooks": { + "preBuild": "./scripts/prebuild.sh", + "postBuild": "./scripts/upload-symbols.sh" + }, "mobai": { "url": "http://localhost:8686", "device_id": "" @@ -374,7 +378,7 @@ the run's inputs and logs. Keep tokens and passwords in the provider's secrets (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets guide](docs/provider-secrets.md)); the build reads those as environment variables too. Names the runner owns are rejected: its own parameters (`SCHEME`, -`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`, +`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, `BUILD_HOOKS`, ...), the signing secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes. Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow @@ -384,6 +388,46 @@ committed workflow rejects the dispatch. Run `builder init` again to refresh `runner.sh`) in a project set up earlier, then commit and push it to the default branch. +### Build hooks + +Hooks are shell commands the runner executes around the build — to generate +code, fetch a config file, upload dSYMs, or notify a chat: + +```json +{ + "hooks": { + "preBuild": "./scripts/prebuild.sh", + "postBuild": "./scripts/upload-symbols.sh" + }, + "profiles": { + "store": { "distribution": "store", "hooks": { "postBuild": "./scripts/notify.sh" } } + } +} +``` + +- `preBuild` runs once every dependency is in place (`flutter pub get`, + `npm install`, `expo prebuild`, XcodeGen, `pod install`) and before the + build number is applied, the signing settings are written and the archive + starts. `postBuild` runs once the IPA exists and before it is uploaded as an + artifact; `BUILDER_IPA` is its absolute path. +- Hooks run on the runner, never on your machine, from the repository root of + the checkout, as `bash -eo pipefail -c ""`: a multi-line command + stops at its first failing line, and a failing hook fails the build (its + exit code is in the job log, under a foldable group on GitHub). +- Each hook sees the profile's `env` plus `BUILDER_HOOK` (`preBuild` or + `postBuild`), `BUILDER_BUILD_ID`, `BUILDER_PROFILE` (empty without a + profile), `BUILDER_CONFIGURATION`, `BUILDER_DISTRIBUTION` (canonical, empty + for unsigned builds), `BUILDER_IOS_PATH`, `BUILDER_PROJECT_TYPE` (`flutter`, + `expo`, `reactnative`, `kmp` or `native`) and `BUILDER_BUILD_NUMBER` (what + `ios release` sent, else empty). +- Top-level `hooks` apply to every build. A profile's `hooks` override them + field by field: a profile's non-empty `preBuild` replaces the top-level + `preBuild`, an absent or blank one keeps it. Whitespace-only is absent. +- Hooks are for `ios build` (and `ios release`) only; `ios share` and the + simulator builds never run them. Like `env`, the commands are stored in + `builder.json` and sent to the provider as plain workflow inputs, so keep + secrets in the provider's secrets and read them from the hook. + ### MobAI Configuration | Field | Description | Default | From ee2cfe3ff4be9f899bb01cb2e2fa039f10d56a2d Mon Sep 17 00:00:00 2001 From: Interlap Date: Fri, 18 Sep 2026 20:18:18 +0200 Subject: [PATCH 4/4] hooks: fail non-string commands by name and pin the hook root A hook whose value was a number or a list vanished on the runner: the tag build's jq merge and the GITHUB_ENV export kept only strings, and runner.sh's hook_command did the same, while the CLI refuses such a builder.json when it decodes Hooks. The Resolve parameters step now checks both hook objects (top level and profile) and the profile input, and check_build_hooks checks BUILD_HOOKS, each failing with the hook's name; null stays "absent". The hook keys travel base64-encoded through the export loop like the env names. run_hook ran from GITHUB_WORKSPACE before BUILDER_WORKSPACE, so runner.sh under GitHub Actions would have left its own checkout, and `cd ""` succeeds silently, so with neither set a hook ran from the iOS directory. The root is now BUILDER_WORKSPACE, else GITHUB_WORKSPACE, and an empty one fails. The providers test points GITHUB_WORKSPACE elsewhere to prove it, TestRunHook covers both cases, and TestCheckBuildHooks runs runner.sh's validation. check_build_hooks no longer prints an empty "hooks:" line for {}. README: BUILDER_DISTRIBUTION is also empty on the legacy ios.signing path, and a command is one string. --- CLAUDE.md | 4 ++ README.md | 8 ++- internal/workflow/hooks_test.go | 71 +++++++++++++++++++++++ internal/workflow/profile_test.go | 29 +++++---- internal/workflow/providers_test.go | 6 +- internal/workflow/templates/ios-build.yml | 24 ++++++-- internal/workflow/templates/runner.sh | 16 ++++- 7 files changed, 136 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd2d197..ea6b13b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,10 @@ internal/ top-level hooks without a profile), a tag build merges them from builder.json in jq, and runner.sh gets `BUILD_HOOKS` (JSON) and `BUILD_PROFILE` (both reserved). The Resolve step exports them as `BUILDER_HOOK_PRE_BUILD`/`BUILDER_HOOK_POST_BUILD` through the same base64 heredoc as the env. + A command must be a string (null = absent): the merge and the export keep only non-blank strings, + so the Resolve step and `check_build_hooks` fail a number or list by name first, as the CLI's + `json.Unmarshal` into `Hooks` would. `run_hook` runs from `BUILDER_WORKSPACE` (runner.sh's + checkout), else `GITHUB_WORKSPACE`, and fails when both are empty, since `cd ""` succeeds silently. Placement: preBuild after `pod install`/project selection and before `apply_build_number`, signing settings and the archive (`Build IPA` step; top of `build_ipa` in runner.sh, not `prepare`, which the simulator mode shares); postBuild after `Created … IPA`, before the artifact upload. The diff --git a/README.md b/README.md index a6c394c..a13c3f0 100644 --- a/README.md +++ b/README.md @@ -416,13 +416,15 @@ code, fetch a config file, upload dSYMs, or notify a chat: exit code is in the job log, under a foldable group on GitHub). - Each hook sees the profile's `env` plus `BUILDER_HOOK` (`preBuild` or `postBuild`), `BUILDER_BUILD_ID`, `BUILDER_PROFILE` (empty without a - profile), `BUILDER_CONFIGURATION`, `BUILDER_DISTRIBUTION` (canonical, empty - for unsigned builds), `BUILDER_IOS_PATH`, `BUILDER_PROJECT_TYPE` (`flutter`, + profile), `BUILDER_CONFIGURATION`, `BUILDER_DISTRIBUTION` (canonical, so + `internal` arrives as `ad-hoc`; empty for unsigned builds and for the legacy + `ios.signing` path), `BUILDER_IOS_PATH`, `BUILDER_PROJECT_TYPE` (`flutter`, `expo`, `reactnative`, `kmp` or `native`) and `BUILDER_BUILD_NUMBER` (what `ios release` sent, else empty). - Top-level `hooks` apply to every build. A profile's `hooks` override them field by field: a profile's non-empty `preBuild` replaces the top-level - `preBuild`, an absent or blank one keeps it. Whitespace-only is absent. + `preBuild`, an absent or blank one keeps it. Whitespace-only is absent. Each + command is one string; a number or a list fails the build by name. - Hooks are for `ios build` (and `ios release`) only; `ios share` and the simulator builds never run them. Like `env`, the commands are stored in `builder.json` and sent to the provider as plain workflow inputs, so keep diff --git a/internal/workflow/hooks_test.go b/internal/workflow/hooks_test.go index 3645ecb..7b0b739 100644 --- a/internal/workflow/hooks_test.go +++ b/internal/workflow/hooks_test.go @@ -200,6 +200,77 @@ echo "recorded" t.Errorf("pipefail: %v\n%s", err, out) } }) + + t.Run("the checkout root is BUILDER_WORKSPACE, else GITHUB_WORKSPACE, never the cwd", func(t *testing.T) { + // `cd ""` succeeds silently, so without either variable the hook + // must not run from wherever the runner happens to be. + marker := filepath.Join(t.TempDir(), "reached") + out, err := run(t, iosDir, map[string]string{"GITHUB_WORKSPACE": ""}, "preBuild", "touch '"+marker+"'") + if err == nil || !strings.Contains(out, "FAIL: preBuild hook: no checkout root") { + t.Errorf("no root: %v\n%s", err, out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Error("the hook ran without a checkout root") + } + // runner.sh's BUILDER_WORKSPACE wins over a GITHUB_WORKSPACE that is + // also set, as when runner.sh itself runs under GitHub Actions. + log := filepath.Join(t.TempDir(), "hook.log") + out, err = run(t, iosDir, map[string]string{"BUILDER_WORKSPACE": iosDir, "HOOK_LOG": log}, "preBuild", record) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + real, _ := filepath.EvalSymlinks(iosDir) + if got := read(t, log)["pwd"]; got != iosDir && got != real { + t.Errorf("hook ran in %q, want BUILDER_WORKSPACE %q", got, iosDir) + } + }) +} + +// TestCheckBuildHooks runs runner.sh's BUILD_HOOKS validation and reader the +// way prepare and build_ipa do: a non-string command fails by name rather +// than being skipped, blank commands are none, and nothing prints for none. +func TestCheckBuildHooks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq not installed") + } + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + script := "set -euo pipefail\n" + shellFunc(t, string(data), "check_build_hooks") + "\n" + shellFunc(t, string(data), "hook_command") + ` +check_build_hooks +printf 'pre=[%s]\n' "$(hook_command preBuild)" +printf 'post=[%s]\n' "$(hook_command postBuild)" +` + path := filepath.Join(t.TempDir(), "check.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name, hooks, want string + fails bool + }{ + {"none", ``, "pre=[]\npost=[]\n", false}, + {"both", `{"preBuild":"echo hi","postBuild":"a\nb"}`, "hooks: preBuild, postBuild\npre=[echo hi]\npost=[a\nb]\n", false}, + {"blank and null are none", `{"preBuild":" \n","postBuild":null}`, "pre=[]\npost=[]\n", false}, + {"empty object", `{}`, "pre=[]\npost=[]\n", false}, + {"not an object", `["echo hi"]`, "BUILD_HOOKS must be a JSON object of preBuild and postBuild commands\n", true}, + {"not JSON", `echo hi`, "BUILD_HOOKS must be a JSON object of preBuild and postBuild commands\n", true}, + {"a number", `{"preBuild":5}`, "BUILD_HOOKS.preBuild must be a string\n", true}, + {"a list", `{"preBuild":"echo hi","postBuild":["x"]}`, "BUILD_HOOKS.postBuild must be a string\n", true}, + } { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command("bash", path) + cmd.Env = append(os.Environ(), "BUILD_HOOKS="+tt.hooks) + out, err := cmd.CombinedOutput() + if (err != nil) != tt.fails || string(out) != tt.want { + t.Errorf("BUILD_HOOKS=%q: err=%v\n%s\nwant:\n%s", tt.hooks, err, out, tt.want) + } + }) + } } // TestBuildHooksPlacement pins where the workflow runs the hooks: preBuild diff --git a/internal/workflow/profile_test.go b/internal/workflow/profile_test.go index 16a84ab..277ff21 100644 --- a/internal/workflow/profile_test.go +++ b/internal/workflow/profile_test.go @@ -242,19 +242,28 @@ func TestResolveParametersApplyProfiles(t *testing.T) { for name, tt := range map[string]struct { json string env map[string]string + want string // in the log }{ - "unknown defaultProfile": {`{"defaultProfile": "nightly", "profiles": {"preview": {}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, - "bad distribution": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "adhoc"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, - "old app-store": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "app-store"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, - "bad env name": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":{"A B":"x"}}`}}, - "env not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":"A=x"}`}}, - "profile not JSON": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `preview`}}, - "hooks not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","hooks":"./scripts/prebuild.sh"}`}}, - "top-level hooks string": {`{"hooks": "./scripts/prebuild.sh"}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, - "profile hooks a list": {`{"defaultProfile": "p", "profiles": {"p": {"hooks": ["./scripts/prebuild.sh"]}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}}, + "unknown defaultProfile": {`{"defaultProfile": "nightly", "profiles": {"preview": {}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, ""}, + "bad distribution": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "adhoc"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, ""}, + "old app-store": {`{"defaultProfile": "p", "profiles": {"p": {"distribution": "app-store"}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, ""}, + "bad env name": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":{"A B":"x"}}`}, ""}, + "env not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","env":"A=x"}`}, ""}, + "profile not JSON": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `preview`}, ""}, + "hooks not an object": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","hooks":"./scripts/prebuild.sh"}`}, "hooks must be a JSON object"}, + "top-level hooks string": {`{"hooks": "./scripts/prebuild.sh"}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, "hooks in builder.json must be an object"}, + "profile hooks a list": {`{"defaultProfile": "p", "profiles": {"p": {"hooks": ["./scripts/prebuild.sh"]}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, "hooks in builder.json must be an object"}, + // A non-string command must fail by name, not vanish: the + // merge and the export keep only strings. + "top-level hook a number": {`{"hooks": {"preBuild": 5}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, "hooks.preBuild in builder.json must be a string"}, + "profile hook a list": {`{"defaultProfile": "p", "profiles": {"p": {"hooks": {"postBuild": ["./scripts/notify.sh"]}}}}`, map[string]string{"GITHUB_EVENT_NAME": "push"}, "hooks.postBuild in builder.json must be a string"}, + "dispatch hook a bool": {``, map[string]string{"GITHUB_EVENT_NAME": "workflow_dispatch", "IN_PROFILE": `{"name":"p","hooks":{"preBuild":true}}`}, "hooks.preBuild must be a string"}, } { - if r := runResolve(t, build, tt.json, tt.env); r.err == nil { + r := runResolve(t, build, tt.json, tt.env) + if r.err == nil { t.Errorf("%s accepted:\n%s", name, r.log) + } else if !strings.Contains(r.log, tt.want) { + t.Errorf("%s: log lacks %q:\n%s", name, tt.want, r.log) } } }) diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index 3883a89..49ae9c9 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -187,8 +187,10 @@ fi cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "SNAPSHOT_REF="+ref, "SNAPSHOT_SHA="+sha, "BUILD_ID=abcdef12", "IOS_PATH=.", "USE_SIGNING=false", "CONFIGURATION=Debug", "BUILD_NUMBER=17", "SCHEME="+scheme, "SCHEME_LOG="+filepath.Join(dir, "scheme.log"), "BUILDER_CI_DIR="+filepath.Join(dir, "state"), "BUILD_ENV="+buildEnv, "DISTRIBUTION=ad-hoc", "ENV_LOG="+filepath.Join(dir, "env.log"), "BUILD_PROFILE=preview", "BUILD_HOOKS="+buildHooks, "HOOK_LOG="+hookLog, - // A GitHub-hosted test run must not point the hooks at its own checkout. - "GITHUB_WORKSPACE=", "GITHUB_ACTIONS=") + // runner.sh's own checkout (BUILDER_WORKSPACE) is the hooks' root + // even under GitHub Actions: the preBuild hook above asserts it, + // so a GITHUB_WORKSPACE pointing elsewhere must not win. + "GITHUB_WORKSPACE="+dir, "GITHUB_ACTIONS=") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("runner: %s %v", out, err) } diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index aed1533..0a41e41 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -147,6 +147,13 @@ jobs: if [ "$(jq -r --arg p "$PROFILE" '[.hooks // {}, .profiles[$p].hooks // {}] | all(type == "object")' builder.json)" != "true" ]; then echo "::error::hooks in builder.json must be an object of preBuild and postBuild commands"; exit 1 fi + # A command is a string (null is absent). The merge below keeps only + # strings, so a number or a list must fail here by name, as the CLI + # refuses it, instead of vanishing. + BAD_HOOK=$(jq -r --arg p "$PROFILE" '[.hooks // {}, .profiles[$p].hooks // {}] | map(to_entries[] | select(.value != null and (.value | type) != "string") | .key) | first // empty' builder.json) + if [ -n "$BAD_HOOK" ]; then + echo "::error::hooks.$BAD_HOOK in builder.json must be a string"; exit 1 + fi PROFILE_JSON=$(jq -c --arg p "$PROFILE" '{name: $p, env: (.profiles[$p].env // {}), distribution: (.profiles[$p].distribution // ""), hooks: ((.hooks // {}) + (.profiles[$p].hooks // {} | with_entries(select(.value | type == "string" and test("\\S")))))}' builder.json) fi @@ -203,8 +210,13 @@ jobs: if [ "$(jq -r '.hooks // {} | type' <<< "$PROFILE_JSON")" != "object" ]; then echo "::error::the profile's hooks must be a JSON object of preBuild and postBuild commands"; exit 1 fi + BAD_HOOK=$(jq -r '.hooks // {} | to_entries[] | select(.value != null and (.value | type) != "string") | .key' <<< "$PROFILE_JSON" | head -1) + if [ -n "$BAD_HOOK" ]; then + echo "::error::the profile's hooks.$BAD_HOOK must be a string"; exit 1 + fi HOOKS="" - while IFS=' ' read -r hook encoded; do + while IFS=' ' read -r key encoded; do + hook=$(printf '%s' "$key" | base64 --decode) case "$hook" in preBuild) name=BUILDER_HOOK_PRE_BUILD ;; postBuild) name=BUILDER_HOOK_POST_BUILD ;; @@ -218,7 +230,7 @@ jobs: echo "$delim" } >> "$GITHUB_ENV" HOOKS="${HOOKS:+$HOOKS, }$hook" - done < <(jq -r '.hooks // {} | to_entries[] | select(.value | type == "string" and test("\\S")) | "\(.key) \(.value | @base64)"' <<< "$PROFILE_JSON") + done < <(jq -r '.hooks // {} | to_entries[] | select(.value | type == "string" and test("\\S")) | "\(.key | @base64) \(.value | @base64)"' <<< "$PROFILE_JSON") [ -z "$HOOKS" ] || echo "hooks: $HOOKS" - name: Setup Xcode @@ -1043,11 +1055,15 @@ jobs: # profile's env is already exported, the BUILDER_* variables describe # the build, and $3 is the IPA a postBuild hook gets as BUILDER_IPA. run_hook() { - local hook="$1" command="$2" ipa="${3:-}" status=0 + local hook="$1" command="$2" ipa="${3:-}" status=0 root="${BUILDER_WORKSPACE:-${GITHUB_WORKSPACE:-}}" [ -n "$command" ] || return 0 + # runner.sh sets BUILDER_WORKSPACE at the checkout; the GitHub workflow + # only has GITHUB_WORKSPACE. `cd ""` succeeds silently, so an empty root + # must fail rather than run the hook from the iOS directory. + [ -n "$root" ] || fail "$hook hook: no checkout root (BUILDER_WORKSPACE or GITHUB_WORKSPACE) to run from" if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::group::$hook hook"; else echo "== $hook hook =="; fi ( - cd "${GITHUB_WORKSPACE:-$BUILDER_WORKSPACE}" || exit 1 + cd "$root" || exit 1 export BUILDER_HOOK="$hook" BUILDER_BUILD_ID="${BUILD_ID:-}" BUILDER_PROFILE="${BUILD_PROFILE:-}" export BUILDER_CONFIGURATION="${CONFIGURATION:-}" BUILDER_DISTRIBUTION="${DISTRIBUTION:-}" export BUILDER_IOS_PATH="${IOS_PATH:-}" BUILDER_PROJECT_TYPE="${PROJECT_TYPE:-}" BUILDER_BUILD_NUMBER="${BUILD_NUMBER:-}" diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 1543f9f..c31fd4f 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -37,7 +37,13 @@ export_build_env() { check_build_hooks() { [ -n "$BUILD_HOOKS" ] || return 0 if [ "$(jq -r 'type' <<< "$BUILD_HOOKS" 2>/dev/null)" != "object" ]; then echo "BUILD_HOOKS must be a JSON object of preBuild and postBuild commands" >&2; exit 1; fi - echo "hooks: $(jq -r '[to_entries[] | select(.value | type == "string" and test("\\S")) | .key] | join(", ")' <<< "$BUILD_HOOKS")" + # A command is a string (null is absent); anything else would be skipped + # silently by hook_command, so it fails here by name instead. + local bad names + bad=$(jq -r 'to_entries[] | select(.value != null and (.value | type) != "string") | .key' <<< "$BUILD_HOOKS" | head -1) + if [ -n "$bad" ]; then echo "BUILD_HOOKS.$bad must be a string" >&2; exit 1; fi + names=$(jq -r '[to_entries[] | select(.value | type == "string" and test("\\S")) | .key] | join(", ")' <<< "$BUILD_HOOKS") + [ -z "$names" ] || echo "hooks: $names" } hook_command() { @@ -52,11 +58,15 @@ hook_command() { # profile's env is already exported, the BUILDER_* variables describe # the build, and $3 is the IPA a postBuild hook gets as BUILDER_IPA. run_hook() { - local hook="$1" command="$2" ipa="${3:-}" status=0 + local hook="$1" command="$2" ipa="${3:-}" status=0 root="${BUILDER_WORKSPACE:-${GITHUB_WORKSPACE:-}}" [ -n "$command" ] || return 0 + # runner.sh sets BUILDER_WORKSPACE at the checkout; the GitHub workflow + # only has GITHUB_WORKSPACE. `cd ""` succeeds silently, so an empty root + # must fail rather than run the hook from the iOS directory. + [ -n "$root" ] || fail "$hook hook: no checkout root (BUILDER_WORKSPACE or GITHUB_WORKSPACE) to run from" if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::group::$hook hook"; else echo "== $hook hook =="; fi ( - cd "${GITHUB_WORKSPACE:-$BUILDER_WORKSPACE}" || exit 1 + cd "$root" || exit 1 export BUILDER_HOOK="$hook" BUILDER_BUILD_ID="${BUILD_ID:-}" BUILDER_PROFILE="${BUILD_PROFILE:-}" export BUILDER_CONFIGURATION="${CONFIGURATION:-}" BUILDER_DISTRIBUTION="${DISTRIBUTION:-}" export BUILDER_IOS_PATH="${IOS_PATH:-}" BUILDER_PROJECT_TYPE="${PROJECT_TYPE:-}" BUILDER_BUILD_NUMBER="${BUILD_NUMBER:-}"