Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions actions/build-push/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cmd/semaloop/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 44 additions & 7 deletions internal/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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
}
Expand All @@ -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")
Expand Down
195 changes: 189 additions & 6 deletions internal/cmd/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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,
Expand Down
Loading