go-build/pkg/build/xcode_cloud.go
Snider a6bc490b54 feat(build): Xcode Cloud ci_scripts materialisation
- pkg/build/xcode_cloud.go (new): detects apple.xcode_cloud config,
  generates the three required ci_scripts files (ci_post_clone.sh,
  ci_pre_xcodebuild.sh, ci_post_xcodebuild.sh) with executable
  permissions
- cmd/build/cmd_apple.go: Apple command emits the scripts whenever
  apple.xcode_cloud is configured
- xcode_cloud_test + cmd_apple_test: coverage for the materialisation
  path

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 16:57:38 +01:00

274 lines
6.9 KiB
Go

package build
import (
"strconv"
"strings"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
const (
// XcodeCloudScriptsDir is the repository-relative directory used by Xcode Cloud.
XcodeCloudScriptsDir = "ci_scripts"
// XcodeCloudPostCloneScriptName installs toolchains and project dependencies.
XcodeCloudPostCloneScriptName = "ci_post_clone.sh"
// XcodeCloudPreXcodebuildScriptName runs the Apple pipeline before xcodebuild.
XcodeCloudPreXcodebuildScriptName = "ci_pre_xcodebuild.sh"
// XcodeCloudPostXcodebuildScriptName verifies the built bundle after xcodebuild.
XcodeCloudPostXcodebuildScriptName = "ci_post_xcodebuild.sh"
)
// HasXcodeCloudConfig reports whether apple.xcode_cloud contains workflow metadata.
func HasXcodeCloudConfig(cfg *BuildConfig) bool {
if cfg == nil {
return false
}
if core.Trim(cfg.Apple.XcodeCloud.Workflow) != "" {
return true
}
return len(cfg.Apple.XcodeCloud.Triggers) > 0
}
// GenerateXcodeCloudScripts renders the three Xcode Cloud helper scripts.
func GenerateXcodeCloudScripts(projectDir string, cfg *BuildConfig) map[string]string {
if cfg == nil {
cfg = DefaultConfig()
}
bundleName := resolveXcodeCloudBundleName(projectDir, cfg)
buildCommand := resolveXcodeCloudBuildCommand(cfg)
return map[string]string{
XcodeCloudPostCloneScriptName: generateXcodeCloudPostCloneScript(),
XcodeCloudPreXcodebuildScriptName: generateXcodeCloudPreXcodebuildScript(buildCommand),
XcodeCloudPostXcodebuildScriptName: generateXcodeCloudPostXcodebuildScript(
bundleName,
),
}
}
// WriteXcodeCloudScripts writes the Xcode Cloud helper scripts to ci_scripts/.
func WriteXcodeCloudScripts(filesystem io.Medium, projectDir string, cfg *BuildConfig) ([]string, error) {
if filesystem == nil {
return nil, coreerr.E("build.WriteXcodeCloudScripts", "filesystem medium is required", nil)
}
scripts := GenerateXcodeCloudScripts(projectDir, cfg)
orderedNames := []string{
XcodeCloudPostCloneScriptName,
XcodeCloudPreXcodebuildScriptName,
XcodeCloudPostXcodebuildScriptName,
}
baseDir := ax.Join(projectDir, XcodeCloudScriptsDir)
if err := filesystem.EnsureDir(baseDir); err != nil {
return nil, coreerr.E("build.WriteXcodeCloudScripts", "failed to create Xcode Cloud scripts directory", err)
}
paths := make([]string, 0, len(orderedNames))
for _, name := range orderedNames {
path := ax.Join(baseDir, name)
if err := filesystem.WriteMode(path, scripts[name], 0o755); err != nil {
return nil, coreerr.E("build.WriteXcodeCloudScripts", "failed to write "+name, err)
}
paths = append(paths, path)
}
return paths, nil
}
func resolveXcodeCloudBundleName(projectDir string, cfg *BuildConfig) string {
if cfg != nil {
if cfg.Project.Binary != "" {
return cfg.Project.Binary
}
if cfg.Project.Name != "" {
return cfg.Project.Name
}
}
if core.Trim(projectDir) == "" {
return "App"
}
return ax.Base(projectDir)
}
func resolveXcodeCloudBuildCommand(cfg *BuildConfig) string {
options := DefaultAppleOptions()
if cfg != nil {
options = cfg.Apple.Resolve()
}
args := []string{
"core",
"build",
"apple",
"--arch",
shellQuote(firstNonEmpty(options.Arch, defaultAppleArch)),
"--config",
shellQuote(ax.Join(ConfigDir, ConfigFileName)),
}
if !options.Sign {
args = append(args, "--sign=false")
}
if !options.Notarise {
args = append(args, "--notarise=false")
}
if options.DMG {
args = append(args, "--dmg")
}
if options.TestFlight {
args = append(args, "--testflight")
}
if options.AppStore {
args = append(args, "--appstore")
}
if core.Trim(options.BundleID) != "" {
args = append(args, "--bundle-id", shellQuote(options.BundleID))
}
if core.Trim(options.TeamID) != "" {
args = append(args, "--team-id", shellQuote(options.TeamID))
}
return strings.Join(args, " ")
}
func generateXcodeCloudPostCloneScript() string {
return strings.TrimSpace(`#!/usr/bin/env bash
set -euo pipefail
export PATH="${HOME}/go/bin:${HOME}/.deno/bin:${HOME}/.bun/bin:${PATH}"
install_node_package_dir() {
local dir="$1"
if [ ! -f "$dir/package.json" ]; then
return 0
fi
if [ -f "$dir/pnpm-lock.yaml" ]; then
corepack enable pnpm
(cd "$dir" && pnpm install --frozen-lockfile)
return 0
fi
if [ -f "$dir/yarn.lock" ]; then
corepack enable yarn
(cd "$dir" && yarn install --immutable)
return 0
fi
if [ -f "$dir/bun.lockb" ] || [ -f "$dir/bun.lock" ]; then
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash
export PATH="${HOME}/.bun/bin:${PATH}"
fi
(cd "$dir" && bun install --frozen-lockfile)
return 0
fi
if [ -f "$dir/package-lock.json" ]; then
(cd "$dir" && npm ci)
return 0
fi
(cd "$dir" && npm install)
}
if ! command -v go >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew install go
else
echo "Go is required for Xcode Cloud builds." >&2
exit 1
fi
fi
if ! command -v node >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew install node
else
echo "Node.js is required for Xcode Cloud builds." >&2
exit 1
fi
fi
if ! command -v wails3 >/dev/null 2>&1 && ! command -v wails >/dev/null 2>&1; then
go install github.com/wailsapp/wails/v3/cmd/wails3@latest
fi
if find . -maxdepth 3 \( -name deno.json -o -name deno.jsonc \) -not -path '*/node_modules/*' | grep -q .; then
if ! command -v deno >/dev/null 2>&1; then
curl -fsSL https://deno.land/install.sh | sh
export PATH="${HOME}/.deno/bin:${PATH}"
fi
fi
install_node_package_dir "."
if [ -d frontend ]; then
install_node_package_dir "./frontend"
fi
while IFS= read -r manifest; do
dir="$(dirname "$manifest")"
case "$dir" in
"."|"./frontend")
continue
;;
esac
install_node_package_dir "$dir"
done < <(find . -maxdepth 3 -name package.json -not -path '*/node_modules/*' | sort)
`) + "\n"
}
func generateXcodeCloudPreXcodebuildScript(buildCommand string) string {
return strings.TrimSpace(core.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
export PATH="${HOME}/go/bin:${HOME}/.deno/bin:${HOME}/.bun/bin:${PATH}"
%s
`, buildCommand)) + "\n"
}
func generateXcodeCloudPostXcodebuildScript(bundleName string) string {
bundlePath := ax.Join("dist", "apple", bundleName+".app")
executablePath := ax.Join(bundlePath, "Contents", "MacOS", bundleName)
return strings.TrimSpace(core.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
BUNDLE_PATH=%s
EXECUTABLE_PATH=%s
if [ ! -d "$BUNDLE_PATH" ]; then
echo "Expected bundle not found: $BUNDLE_PATH" >&2
exit 1
fi
if [ ! -x "$EXECUTABLE_PATH" ]; then
echo "Expected executable not found: $EXECUTABLE_PATH" >&2
exit 1
fi
if command -v codesign >/dev/null 2>&1; then
codesign --verify --deep --strict "$BUNDLE_PATH"
fi
if command -v spctl >/dev/null 2>&1; then
spctl --assess --type execute "$BUNDLE_PATH" || true
fi
`, shellQuote(bundlePath), shellQuote(executablePath))) + "\n"
}
func shellQuote(value string) string {
return strconv.Quote(value)
}