diff --git a/CLAUDE.md b/CLAUDE.md index 555aafd..ea6b13b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,12 +246,31 @@ 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. + 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 + `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 +455,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 +472,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 +492,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..a13c3f0 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,48 @@ 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, 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. 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 + secrets in the provider's secrets and read them from the hook. + ### MobAI Configuration | Field | Description | Default | 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/internal/workflow/hooks_test.go b/internal/workflow/hooks_test.go new file mode 100644 index 0000000..7b0b739 --- /dev/null +++ b/internal/workflow/hooks_test.go @@ -0,0 +1,320 @@ +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) + } + }) + + 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 +// 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..277ff21 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,28 +208,62 @@ 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) { 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`}}, + "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 bbf3d4d..49ae9c9 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -178,14 +178,36 @@ 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, + // 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) } 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..0a41e41 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,24 @@ 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 + # 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 [ -n "${PROFILE_JSON:-}" ] || PROFILE_JSON='{}' PROFILE=$(jq -r '.name // ""' <<< "$PROFILE_JSON") @@ -190,6 +203,35 @@ 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 + 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 key encoded; do + hook=$(printf '%s' "$key" | base64 --decode) + 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 | @base64) \(.value | @base64)"' <<< "$PROFILE_JSON") + [ -z "$HOOKS" ] || echo "hooks: $HOOKS" - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 @@ -868,6 +910,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 +1048,33 @@ 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 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 "$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:-}" + 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 +1139,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 +1345,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..c31fd4f 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,52 @@ 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 + # 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() { + [ -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 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 "$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:-}" + 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 +257,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 +659,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 +710,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() { 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 )