From d7d67cbfe9429e40a9d2e0a74ac1221f72b36d19 Mon Sep 17 00:00:00 2001 From: Sam Starling <42478+samstarling@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:21:41 +0100 Subject: [PATCH] feat: accept android .apk build artifacts The API now infers platform from the uploaded file, recognising a standalone APK by a root-level AndroidManifest.xml, so no request fields change. The only CLI blocker was validateArtifact, which rejected .apk outright. Validate APKs the way we validate IPAs: open the zip and require the manifest at the root. Archives that bundle APKs inside them (XAPK, APK sets) are caught locally with the same guidance the API gives, saving a full upload round trip. Push already streams files unmodified, which the API requires for APKs. Extensions are now matched case-insensitively, since Android build pipelines produce more varied filename casing than Xcode does. Android support is a limited beta, so the docs keep their iOS framing and note the beta in one line rather than advertising it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + actions/build-push/action.yml | 4 +- cmd/semaloop/build.go | 2 +- internal/cmd/build.go | 51 +++++++-- internal/cmd/build_test.go | 195 ++++++++++++++++++++++++++++++++-- 5 files changed, 238 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f41ad35..65f35fe 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Allows you to authenticate with Semaloop using an API key, and remove any existi Allows you to push an iOS build artifact (`.app` or `.ipa`) for testing. +Android `.apk` uploads are in a limited beta; talk to us before using them. + You can specify `--git-repo`, `--git-commit` and `--git-ref`, which allows Semaloop to report the results back as a status check on the commit or pull request. You must have connected your repository to Semaloop via our web dashboard for this to work. All three arguments must be specified. ## GitHub Actions diff --git a/actions/build-push/action.yml b/actions/build-push/action.yml index fccac9b..e135377 100644 --- a/actions/build-push/action.yml +++ b/actions/build-push/action.yml @@ -31,8 +31,8 @@ inputs: default: ${{ github.head_ref && format('refs/heads/{0}', github.head_ref) || github.ref }} allow-duplicate-version: description: > - Accept an upload whose bundle, version label, and version name already - exist, recording it as a distinct build instead of rejecting it. + Accept an upload whose version label and version name already exist for + this app, recording it as a distinct build instead of rejecting it. required: false default: 'false' dry-run: diff --git a/cmd/semaloop/build.go b/cmd/semaloop/build.go index 17482d4..ef25eca 100644 --- a/cmd/semaloop/build.go +++ b/cmd/semaloop/build.go @@ -24,7 +24,7 @@ type BuildPushCmd struct { GitRepo string `help:"Source repository (owner/name) the build was produced from." name:"git-repo" and:"gitref"` GitCommit string `help:"Commit SHA the build was produced from." name:"git-commit" and:"gitref"` GitRef string `help:"Git ref (e.g. refs/heads/main) the build was produced from." name:"git-ref" and:"gitref"` - AllowDuplicateVersion bool `help:"Accept an upload whose bundle, version label, and version name already exist, recording it as a distinct build instead of rejecting it." name:"allow-duplicate-version"` + AllowDuplicateVersion bool `help:"Accept an upload whose version label and version name already exist for this app, recording it as a distinct build instead of rejecting it." name:"allow-duplicate-version"` } func (c *BuildPushCmd) Run(g *Globals) error { diff --git a/internal/cmd/build.go b/internal/cmd/build.go index 06ccb24..7ba8d61 100644 --- a/internal/cmd/build.go +++ b/internal/cmd/build.go @@ -33,14 +33,15 @@ type PushOptions struct { Commit string Ref string - // AllowDuplicateVersion accepts an upload whose (bundle, version label, - // version name) already exists, recording it as a distinct build instead - // of rejecting it. Defaults to false. + // AllowDuplicateVersion accepts an upload whose version label and version + // name already exist for this app, recording it as a distinct build + // instead of rejecting it. Defaults to false. AllowDuplicateVersion bool } // Push creates a build upload and streams the file to the returned URL. -// If filePath is a directory it is zipped into a temporary file first. +// If filePath is a directory it is zipped into a temporary file first; .ipa +// and .apk files are sent as-is, since the API expects the raw archive. func Push(ctx context.Context, apiKey, serverURL, filePath string, opts PushOptions) (PushResult, error) { filePath = filepath.Clean(filePath) info, err := os.Stat(filePath) @@ -191,9 +192,12 @@ func UploadFile(path, uploadURL string) (int, error) { return resp.StatusCode, nil } -// validateArtifact checks that path is a .app bundle or .ipa file. +// validateArtifact checks that path is a .app bundle, .ipa file or .apk file. func validateArtifact(path string, info os.FileInfo) error { - ext := filepath.Ext(path) + // Case-folded: Android CI produces more varied filename casing than Xcode, + // so an `app-release.APK` should be recognised rather than reported as an + // unsupported artifact. + ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".app": if !info.IsDir() { @@ -209,8 +213,15 @@ func validateArtifact(path string, info os.FileInfo) error { if err := validateIPA(path); err != nil { return err } + case ".apk": + if info.IsDir() { + return fmt.Errorf("%q is not a valid .apk file (expected a file, not a directory)", path) + } + if err := validateAPK(path); err != nil { + return err + } default: - return fmt.Errorf("%q is not a supported iOS artifact (expected .app or .ipa)", path) + return fmt.Errorf("%q is not a supported build artifact (expected .app, .ipa or .apk)", path) } return nil } @@ -232,6 +243,32 @@ func validateIPA(path string) error { return fmt.Errorf("%q does not appear to be a valid .ipa file (Payload/ not found)", path) } +// validateAPK verifies that path is a zip archive with AndroidManifest.xml at +// its root, which is the structural marker the API uses to recognise a +// standalone APK. Archives that bundle APKs inside them (XAPK, APK sets) are +// rejected here with the same guidance the API gives, to save a round trip. +func validateAPK(path string) error { + zr, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("%q does not appear to be a valid .apk file (not a zip archive): %w", path, err) + } + defer zr.Close() + + containsAPK := false + for _, f := range zr.File { + if f.Name == "AndroidManifest.xml" { + return nil + } + if strings.HasSuffix(strings.ToLower(f.Name), ".apk") { + containsAPK = true + } + } + if containsAPK { + return fmt.Errorf("%q looks like an XAPK or APK set, which is not supported (upload a standalone .apk file)", path) + } + return fmt.Errorf("%q does not appear to be a valid .apk file (AndroidManifest.xml not found)", path) +} + // zipDir creates a temporary zip archive of the directory at src and returns its path. func zipDir(src string) (string, error) { tmp, err := os.CreateTemp("", "semaloop-upload-*.zip") diff --git a/internal/cmd/build_test.go b/internal/cmd/build_test.go index 53ff787..936a161 100644 --- a/internal/cmd/build_test.go +++ b/internal/cmd/build_test.go @@ -184,7 +184,7 @@ func makeAppBundle(t *testing.T) string { func makeIPAFile(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "App.ipa") - writeIPA(t, path, func(zw *zip.Writer) { + writeZip(t, path, func(zw *zip.Writer) { if _, err := zw.Create("Payload/Demo.app/"); err != nil { t.Fatal(err) } @@ -199,8 +199,25 @@ func makeIPAFile(t *testing.T) string { return path } -// writeIPA writes a zip archive at path, calling fn to populate its entries. -func writeIPA(t *testing.T, path string, fn func(*zip.Writer)) { +// makeAPKFile creates a minimal valid .apk (a zip archive with a root-level +// AndroidManifest.xml entry) at a path inside t.TempDir. +func makeAPKFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "App.apk") + writeZip(t, path, func(zw *zip.Writer) { + w, err := zw.Create("AndroidManifest.xml") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("binary xml")); err != nil { + t.Fatal(err) + } + }) + return path +} + +// writeZip writes a zip archive at path, calling fn to populate its entries. +func writeZip(t *testing.T, path string, fn func(*zip.Writer)) { t.Helper() f, err := os.Create(path) if err != nil { @@ -283,7 +300,7 @@ func TestValidateArtifactIPANotZip(t *testing.T) { func TestValidateArtifactIPAMissingPayload(t *testing.T) { path := filepath.Join(t.TempDir(), "App.ipa") - writeIPA(t, path, func(zw *zip.Writer) { + writeZip(t, path, func(zw *zip.Writer) { w, err := zw.Create("README.txt") if err != nil { t.Fatal(err) @@ -302,6 +319,126 @@ func TestValidateArtifactIPAMissingPayload(t *testing.T) { } } +func TestValidateArtifactValidAPK(t *testing.T) { + path := makeAPKFile(t) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := validateArtifact(path, info); err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestValidateArtifactAPKAsDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.apk") + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + err = validateArtifact(path, info) + if err == nil || !strings.Contains(err.Error(), "expected a file") { + t.Errorf("expected 'expected a file' error, got %v", err) + } +} + +func TestValidateArtifactAPKNotZip(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.apk") + if err := os.WriteFile(path, []byte("not a zip"), 0644); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + err = validateArtifact(path, info) + if err == nil || !strings.Contains(err.Error(), "not a zip archive") { + t.Errorf("expected 'not a zip archive' error, got %v", err) + } +} + +func TestValidateArtifactAPKMissingManifest(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.apk") + writeZip(t, path, func(zw *zip.Writer) { + w, err := zw.Create("classes.dex") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("dex")); err != nil { + t.Fatal(err) + } + }) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + err = validateArtifact(path, info) + if err == nil || !strings.Contains(err.Error(), "AndroidManifest.xml not found") { + t.Errorf("expected 'AndroidManifest.xml not found' error, got %v", err) + } +} + +// TestValidateArtifactAPKSet covers XAPK / APK-set containers, which are a zip +// of APKs rather than an APK. The API rejects these at finalize; catching them +// locally saves the upload. +func TestValidateArtifactAPKSet(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.apk") + writeZip(t, path, func(zw *zip.Writer) { + for _, name := range []string{"base.apk", "split_config.arm64_v8a.apk"} { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("apk")); err != nil { + t.Fatal(err) + } + } + }) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + err = validateArtifact(path, info) + if err == nil || !strings.Contains(err.Error(), "XAPK or APK set") { + t.Errorf("expected 'XAPK or APK set' error, got %v", err) + } +} + +// TestValidateArtifactUppercaseExtensions covers artifacts whose extension is +// not lowercase, which Android build pipelines produce more often than Xcode. +func TestValidateArtifactUppercaseExtensions(t *testing.T) { + apk := makeAPKFile(t) + upperAPK := filepath.Join(filepath.Dir(apk), "App.APK") + if err := os.Rename(apk, upperAPK); err != nil { + t.Fatal(err) + } + + ipa := makeIPAFile(t) + upperIPA := filepath.Join(filepath.Dir(ipa), "App.IPA") + if err := os.Rename(ipa, upperIPA); err != nil { + t.Fatal(err) + } + + app := makeAppBundle(t) + upperApp := filepath.Join(filepath.Dir(app), "App.App") + if err := os.Rename(app, upperApp); err != nil { + t.Fatal(err) + } + + for _, path := range []string{upperAPK, upperIPA, upperApp} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := validateArtifact(path, info); err != nil { + t.Errorf("%s: expected nil, got %v", filepath.Base(path), err) + } + } +} + func TestValidateArtifactUnsupportedExtension(t *testing.T) { path := filepath.Join(t.TempDir(), "App.zip") if err := os.WriteFile(path, []byte("data"), 0644); err != nil { @@ -312,8 +449,8 @@ func TestValidateArtifactUnsupportedExtension(t *testing.T) { t.Fatal(err) } err = validateArtifact(path, info) - if err == nil || !strings.Contains(err.Error(), "expected .app or .ipa") { - t.Errorf("expected 'expected .app or .ipa' error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "expected .app, .ipa or .apk") { + t.Errorf("expected 'expected .app, .ipa or .apk' error, got %v", err) } } @@ -379,6 +516,52 @@ func TestPushReturnsUploadID_IPA(t *testing.T) { } } +// TestPushReturnsUploadID_APK verifies an .apk is streamed to the pre-signed URL +// byte-for-byte. The API rejects an APK wrapped in another zip, so Push must not +// repackage it. +func TestPushReturnsUploadID_APK(t *testing.T) { + const uploadID = "apk-123" + + var uploaded bytes.Buffer + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/uploads": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, createOKBody(uploadID, srv.URL+"/upload")) + case r.Method == http.MethodPut && r.URL.Path == "/upload": + io.Copy(&uploaded, r.Body) + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/uploads/finalize": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, finalizeOKBody()) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + apkPath := makeAPKFile(t) + expected, err := os.ReadFile(apkPath) + if err != nil { + t.Fatal(err) + } + + result, err := Push(context.Background(), "key", srv.URL, apkPath, PushOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.UploadID != uploadID { + t.Errorf("expected UploadID %q, got %q", uploadID, result.UploadID) + } + if !bytes.Equal(uploaded.Bytes(), expected) { + t.Errorf("expected uploaded body to equal raw .apk bytes (%d bytes), got %d bytes", len(expected), uploaded.Len()) + } +} + func TestPushCreateUploadUnauthorized(t *testing.T) { srv := pushServer{ createStatus: http.StatusUnauthorized,