• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fyne-io / fyne / 6455690839

09 Oct 2023 10:58AM UTC coverage: 65.315%. Remained the same
6455690839

push

github

andydotxyz
Merge branch 'release/v2.4.x'

158 of 158 new or added lines in 23 files covered. (100.0%)

22682 of 34727 relevant lines covered (65.32%)

832.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

3.61
/cmd/fyne/internal/commands/release.go
1
package commands
2

3
import (
4
        "errors"
5
        "flag"
6
        "fmt"
7
        "os"
8
        "path/filepath"
9
        "strings"
10
        "text/template"
11

12
        "fyne.io/fyne/v2"
13
        "fyne.io/fyne/v2/cmd/fyne/internal/mobile"
14
        "fyne.io/fyne/v2/cmd/fyne/internal/templates"
15

16
        "github.com/urfave/cli/v2"
17
        "golang.org/x/sys/execabs"
18
)
19

20
var macAppStoreCategories = []string{
21
        "business", "developer-tools", "education", "entertainment", "finance", "games", "action-games",
22
        "adventure-games", "arcade-games", "board-games", "card-games", "casino-games", "dice-games",
23
        "educational-games", "family-games", "kids-games", "music-games", "puzzle-games", "racing-games",
24
        "role-playing-games", "simulation-games", "sports-games", "strategy-games", "trivia-games", "word-games",
25
        "graphics-design", "healthcare-fitness", "lifestyle", "medical", "music", "news", "photography",
26
        "productivity", "reference", "social-networking", "sports", "travel", "utilities", "video", "weather",
27
}
28

29
// Release returns the cli command for bundling release builds of fyne applications
30
func Release() *cli.Command {
×
31
        r := NewReleaser()
×
32

×
33
        return &cli.Command{
×
34
                Name:  "release",
×
35
                Usage: "Prepares an application for public distribution.",
×
36
                Flags: []cli.Flag{
×
37
                        &cli.StringFlag{
×
38
                                Name:        "target",
×
39
                                Aliases:     []string{"os"},
×
40
                                Usage:       "The operating system to target (android, android/arm, android/arm64, android/amd64, android/386, darwin, freebsd, ios, linux, netbsd, openbsd, windows)",
×
41
                                Destination: &r.os,
×
42
                        },
×
43
                        &cli.StringFlag{
×
44
                                Name:        "keyStore",
×
45
                                Usage:       "Android: location of .keystore file containing signing information",
×
46
                                Destination: &r.keyStore,
×
47
                        },
×
48
                        &cli.StringFlag{
×
49
                                Name:        "keyStorePass",
×
50
                                Usage:       "Android: password for the .keystore file, default take the password from stdin",
×
51
                                Destination: &r.keyStorePass,
×
52
                        },
×
53
                        &cli.StringFlag{
×
54
                                Name:        "keyName",
×
55
                                Usage:       "Android: alias for the signer's private key, which is needed when reading a .keystore file",
×
56
                                Destination: &r.keyName,
×
57
                        },
×
58
                        &cli.StringFlag{
×
59
                                Name:        "keyPass",
×
60
                                Usage:       "Android: password for the signer's private key, which is needed if the private key is password-protected. Default take the password from stdin",
×
61
                                Destination: &r.keyStorePass,
×
62
                        },
×
63
                        &cli.StringFlag{
×
64
                                Name:        "name",
×
65
                                Usage:       "The name of the application, default is the executable file name",
×
66
                                Destination: &r.Name,
×
67
                        },
×
68
                        &cli.StringFlag{
×
69
                                Name:        "tags",
×
70
                                Usage:       "A comma-separated list of build tags.",
×
71
                                Destination: &r.tags,
×
72
                        },
×
73
                        &cli.StringFlag{
×
74
                                Name:        "appVersion",
×
75
                                Usage:       "Version number in the form x, x.y or x.y.z semantic version",
×
76
                                Destination: &r.AppVersion,
×
77
                        },
×
78
                        &cli.IntFlag{
×
79
                                Name:        "appBuild",
×
80
                                Usage:       "Build number, should be greater than 0 and incremented for each build",
×
81
                                Destination: &r.AppBuild,
×
82
                        },
×
83
                        &cli.StringFlag{
×
84
                                Name:        "appID",
×
85
                                Aliases:     []string{"id"},
×
86
                                Usage:       "For Android, darwin, iOS and Windows targets an appID in the form of a reversed domain name is required, for ios this must match a valid provisioning profile",
×
87
                                Destination: &r.AppID,
×
88
                        },
×
89
                        &cli.StringFlag{
×
90
                                Name:        "certificate",
×
91
                                Aliases:     []string{"cert"},
×
92
                                Usage:       "iOS/macOS/Windows: name of the certificate to sign the build",
×
93
                                Destination: &r.certificate,
×
94
                        },
×
95
                        &cli.StringFlag{
×
96
                                Name:        "profile",
×
97
                                Usage:       "iOS/macOS: name of the provisioning profile for this release build",
×
98
                                Destination: &r.profile,
×
99
                        },
×
100
                        &cli.StringFlag{
×
101
                                Name:        "developer",
×
102
                                Aliases:     []string{"dev"},
×
103
                                Usage:       "Windows: the developer identity for your Microsoft store account",
×
104
                                Destination: &r.developer,
×
105
                        },
×
106
                        &cli.StringFlag{
×
107
                                Name:        "password",
×
108
                                Aliases:     []string{"passw"},
×
109
                                Usage:       "Windows: password for the certificate used to sign the build",
×
110
                                Destination: &r.password,
×
111
                        },
×
112
                        &cli.StringFlag{
×
113
                                Name:        "category",
×
114
                                Usage:       "macOS: category of the app for store listing",
×
115
                                Destination: &r.category,
×
116
                        },
×
117
                        &cli.StringFlag{
×
118
                                Name:        "icon",
×
119
                                Usage:       "The name of the application icon file.",
×
120
                                Value:       "",
×
121
                                Destination: &r.icon,
×
122
                        },
×
123
                        &cli.BoolFlag{
×
124
                                Name:        "use-raw-icon",
×
125
                                Usage:       "Skip any OS-specific icon pre-processing",
×
126
                                Value:       false,
×
127
                                Destination: &r.rawIcon,
×
128
                        },
×
129
                        &cli.GenericFlag{
×
130
                                Name:  "metadata",
×
131
                                Usage: "Specify custom metadata key value pair that you do not want to store in your FyneApp.toml (key=value)",
×
132
                                Value: &r.customMetadata,
×
133
                        },
×
134
                },
×
135
                Action: r.releaseAction,
×
136
        }
×
137
}
×
138

139
// Releaser adapts app packages form distribution.
140
type Releaser struct {
141
        Packager
142

143
        keyStore     string
144
        keyStorePass string
145
        keyName      string
146
        keyPass      string
147
        developer    string
148
        password     string
149
}
150

151
// NewReleaser returns a command that can handle the packaging a GUI apps for release from local Fyne source code.
152
func NewReleaser() *Releaser {
×
153
        r := &Releaser{}
×
154
        r.appData = &appData{}
×
155
        return r
×
156
}
×
157

158
// AddFlags adds the flags for interacting with the release command.
159
//
160
// Deprecated: Access to the individual cli commands are being removed.
161
func (r *Releaser) AddFlags() {
×
162
        flag.StringVar(&r.os, "os", "", "The operating system to target (android, android/arm, android/arm64, android/amd64, android/386, darwin, freebsd, ios, linux, netbsd, openbsd, windows)")
×
163
        flag.StringVar(&r.Name, "name", "", "The name of the application, default is the executable file name")
×
164
        flag.StringVar(&r.icon, "icon", "", "The name of the application icon file")
×
165
        flag.StringVar(&r.AppID, "appID", "", "For ios or darwin targets an appID is required, for ios this must \nmatch a valid provisioning profile")
×
166
        flag.StringVar(&r.AppVersion, "appVersion", "", "Version number in the form x, x.y or x.y.z semantic version")
×
167
        flag.IntVar(&r.AppBuild, "appBuild", 0, "Build number, should be greater than 0 and incremented for each build")
×
168
        flag.StringVar(&r.keyStore, "keyStore", "", "Android: location of .keystore file containing signing information")
×
169
        flag.StringVar(&r.keyStorePass, "keyStorePass", "", "Android: password for the .keystore file, default take the password from stdin")
×
170
        flag.StringVar(&r.keyName, "keyName", "", "Android: alias for the signer's private key, which is needed when reading a .keystore file")
×
171
        flag.StringVar(&r.keyPass, "keyPass", "", "Android: password for the signer's private key, which is needed if the private key is password-protected. Default take the password from stdin")
×
172
        flag.StringVar(&r.certificate, "certificate", "", "iOS/macOS/Windows: name of the certificate to sign the build")
×
173
        flag.StringVar(&r.profile, "profile", "", "iOS/macOS: name of the provisioning profile for this release build")
×
174
        flag.StringVar(&r.developer, "developer", "", "Windows: the developer identity for your Microsoft store account")
×
175
        flag.StringVar(&r.password, "password", "", "Windows: password for the certificate used to sign the build")
×
176
        flag.StringVar(&r.tags, "tags", "", "A comma-separated list of build tags")
×
177
        flag.StringVar(&r.category, "category", "", "macOS: category of the app for store listing")
×
178
}
×
179

180
// PrintHelp prints the help message for the release command.
181
//
182
// Deprecated: Access to the individual cli commands are being removed.
183
func (r *Releaser) PrintHelp(indent string) {
×
184
        fmt.Println(indent, "The release command prepares an application for public distribution.")
×
185
}
×
186

187
// Run runs the release command.
188
//
189
// Deprecated: A better version will be exposed in the future.
190
func (r *Releaser) Run(params []string) {
×
191
        if err := r.validate(); err != nil {
×
192
                fmt.Fprintf(os.Stderr, "%s\n", err.Error())
×
193
                return
×
194
        }
×
195

196
        r.Packager.distribution = true
×
197
        r.Packager.release = true
×
198

×
199
        if err := r.beforePackage(); err != nil {
×
200
                fmt.Fprintf(os.Stderr, "%s\n", err.Error())
×
201
                return
×
202
        }
×
203
        r.Packager.Run(params)
×
204
        if err := r.afterPackage(); err != nil {
×
205
                fmt.Fprintf(os.Stderr, "%s\n", err.Error())
×
206
        }
×
207
}
208

209
func (r *Releaser) releaseAction(_ *cli.Context) error {
×
210
        if err := r.validate(); err != nil {
×
211
                return err
×
212
        }
×
213

214
        r.Packager.distribution = true
×
215
        r.Packager.release = true
×
216

×
217
        if err := r.beforePackage(); err != nil {
×
218
                return err
×
219
        }
×
220

221
        if err := r.Packager.packageWithoutValidate(); err != nil {
×
222
                return err
×
223
        }
×
224

225
        if err := r.afterPackage(); err != nil {
×
226
                return err
×
227
        }
×
228

229
        return nil
×
230
}
231

232
func (r *Releaser) afterPackage() error {
×
233
        if util.IsAndroid(r.os) {
×
234
                target := mobile.AppOutputName(r.os, r.Packager.Name, r.release)
×
235
                apk := filepath.Join(r.dir, target)
×
236
                if err := r.zipAlign(apk); err != nil {
×
237
                        return err
×
238
                }
×
239
                return r.signAndroid(apk)
×
240
        }
241
        if r.os == "darwin" {
×
242
                return r.packageMacOSRelease()
×
243
        }
×
244
        if r.os == "ios" {
×
245
                return r.packageIOSRelease()
×
246
        }
×
247
        if r.os == "windows" {
×
248
                outName := r.Name + ".appx"
×
249
                if pos := strings.LastIndex(r.Name, ".exe"); pos > 0 {
×
250
                        outName = r.Name[:pos] + ".appx"
×
251
                }
×
252

253
                outPath := filepath.Join(r.dir, outName)
×
254
                os.Remove(outPath) // MakeAppx will hang if the file exists... ignore result
×
255
                if err := r.packageWindowsRelease(outPath); err != nil {
×
256
                        return err
×
257
                }
×
258
                return r.signWindows(outPath)
×
259
        }
260
        return nil
×
261
}
262

263
func (r *Releaser) beforePackage() error {
×
264
        if util.IsAndroid(r.os) {
×
265
                if err := util.RequireAndroidSDK(); err != nil {
×
266
                        return err
×
267
                }
×
268
        }
269

270
        return nil
×
271
}
272

273
func (r *Releaser) nameFromCertInfo(info string) string {
4✔
274
        // format should be "CN=Company, O=Company, L=City, S=State, C=Country"
4✔
275
        parts := strings.Split(info, ",")
4✔
276
        cn := parts[0]
4✔
277
        pos := strings.Index(strings.ToUpper(cn), "CN=")
4✔
278
        if pos == -1 {
6✔
279
                return cn // not what we were expecting, but should be OK
2✔
280
        }
2✔
281

282
        return cn[pos+3:]
2✔
283
}
284

285
func (r *Releaser) packageIOSRelease() error {
×
286
        team, err := mobile.DetectIOSTeamID(r.certificate)
×
287
        if err != nil {
×
288
                return errors.New("failed to determine team ID")
×
289
        }
×
290

291
        payload := filepath.Join(r.dir, "Payload")
×
292
        _ = os.Mkdir(payload, 0750)
×
293
        defer os.RemoveAll(payload)
×
294
        appName := mobile.AppOutputName(r.os, r.Name, r.release)
×
295
        payloadAppDir := filepath.Join(payload, appName)
×
296
        if err := os.Rename(filepath.Join(r.dir, appName), payloadAppDir); err != nil {
×
297
                return err
×
298
        }
×
299

300
        cleanup, err := r.writeEntitlements(templates.EntitlementsDarwinMobile, struct{ TeamID, AppID string }{
×
301
                TeamID: team,
×
302
                AppID:  r.AppID,
×
303
        })
×
304
        if err != nil {
×
305
                return errors.New("failed to write entitlements plist template")
×
306
        }
×
307
        defer cleanup()
×
308

×
309
        cmd := execabs.Command("codesign", "-f", "-vv", "-s", r.certificate, "--entitlements",
×
310
                "entitlements.plist", "Payload/"+appName+"/")
×
311
        if err := cmd.Run(); err != nil {
×
312
                fyne.LogError("Codesign failed", err)
×
313
                return errors.New("unable to codesign application bundle")
×
314
        }
×
315

316
        return execabs.Command("zip", "-r", appName[:len(appName)-4]+".ipa", "Payload/").Run()
×
317
}
318

319
func (r *Releaser) packageMacOSRelease() error {
×
320
        // try to derive two certificates from one name (they will be consistent)
×
321
        appCert := strings.Replace(r.certificate, "Installer", "Application", 1)
×
322
        installCert := strings.Replace(r.certificate, "Application", "Installer", 1)
×
323
        unsignedPath := r.Name + "-unsigned.pkg"
×
324

×
325
        defer os.RemoveAll(r.Name + ".app") // this was the output of package and it can get in the way of future builds
×
326

×
327
        cleanup, err := r.writeEntitlements(templates.EntitlementsDarwin, nil)
×
328
        if err != nil {
×
329
                return errors.New("failed to write entitlements plist template")
×
330
        }
×
331
        defer cleanup()
×
332

×
333
        cmd := execabs.Command("codesign", "-vfs", appCert, "--entitlement", "entitlements.plist", r.Name+".app")
×
334
        err = cmd.Run()
×
335
        if err != nil {
×
336
                fyne.LogError("Codesign failed", err)
×
337
                return errors.New("unable to codesign application bundle")
×
338
        }
×
339

340
        cmd = execabs.Command("productbuild", "--component", r.Name+".app", "/Applications/",
×
341
                "--product", r.Name+".app/Contents/Info.plist", unsignedPath)
×
342
        err = cmd.Run()
×
343
        if err != nil {
×
344
                fyne.LogError("Product build failed", err)
×
345
                return errors.New("unable to build macOS app package")
×
346
        }
×
347
        defer os.Remove(unsignedPath)
×
348

×
349
        cmd = execabs.Command("productsign", "--sign", installCert, unsignedPath, r.Name+".pkg")
×
350
        return cmd.Run()
×
351
}
352

353
func (r *Releaser) packageWindowsRelease(outFile string) error {
×
354
        payload := filepath.Join(r.dir, "Payload")
×
355
        _ = os.Mkdir(payload, 0750)
×
356
        defer os.RemoveAll(payload)
×
357

×
358
        manifestPath := filepath.Join(payload, "appxmanifest.xml")
×
359
        manifest, err := os.Create(manifestPath)
×
360
        if err != nil {
×
361
                return err
×
362
        }
×
363
        manifestData := struct{ AppID, Developer, DeveloperName, Name, Version string }{
×
364
                AppID: r.AppID,
×
365
                // TODO read this info
×
366
                Developer:     encodeXMLString(r.developer),
×
367
                DeveloperName: encodeXMLString(r.nameFromCertInfo(r.developer)),
×
368
                Name:          encodeXMLString(r.Name),
×
369
                Version:       r.combinedVersion(),
×
370
        }
×
371
        err = templates.AppxManifestWindows.Execute(manifest, manifestData)
×
372
        manifest.Close()
×
373
        if err != nil {
×
374
                return errors.New("failed to write application manifest template")
×
375
        }
×
376

377
        util.CopyFile(r.icon, filepath.Join(payload, "Icon.png"))
×
378
        util.CopyFile(r.Name, filepath.Join(payload, r.Name))
×
379

×
380
        binDir, err := findWindowsSDKBin()
×
381
        if err != nil {
×
382
                return errors.New("cannot find makeappx.exe, make sure you have installed the Windows SDK")
×
383
        }
×
384

385
        cmd := execabs.Command(filepath.Join(binDir, "makeappx.exe"), "pack", "/d", payload, "/p", outFile)
×
386
        cmd.Stdout = os.Stdout
×
387
        cmd.Stderr = os.Stderr
×
388
        cmd.Stdin = os.Stdin
×
389
        return cmd.Run()
×
390
}
391

392
func (r *Releaser) signAndroid(path string) error {
×
393
        signer := "jarsigner"
×
394
        var args []string
×
395
        if r.release {
×
396
                args = []string{"-keystore", r.keyStore}
×
397
        } else {
×
398
                signer = filepath.Join(util.AndroidBuildToolsPath(), "/apksigner")
×
399
                args = []string{"sign", "--ks", r.keyStore}
×
400
        }
×
401

402
        if r.keyStorePass != "" {
×
403
                if r.release {
×
404
                        args = append(args, "-storepass", r.keyStorePass)
×
405
                } else {
×
406
                        args = append(args, "--ks-pass", "pass:"+r.keyStorePass)
×
407
                }
×
408
        }
409
        if r.keyPass != "" {
×
410
                if r.release {
×
411
                        args = append(args, "-keypass", r.keyPass)
×
412
                } else {
×
413
                        args = append(args, "--key-pass", "pass:"+r.keyPass)
×
414
                }
×
415
        }
416
        args = append(args, path)
×
417
        if r.release {
×
418
                if r.keyName == "" { // Required to sign Google Play .aab
×
419
                        return errors.New("missing required -keyName (alias) parameter")
×
420
                }
×
421
                args = append(args, r.keyName)
×
422
        }
423

424
        cmd := execabs.Command(signer, args...)
×
425
        cmd.Stdout = os.Stdout
×
426
        cmd.Stderr = os.Stderr
×
427
        cmd.Stdin = os.Stdin
×
428
        return cmd.Run()
×
429
}
430

431
func (r *Releaser) signWindows(appx string) error {
×
432
        binDir, err := findWindowsSDKBin()
×
433
        if err != nil {
×
434
                return errors.New("cannot find signtool.exe, make sure you have installed the Windows SDK")
×
435
        }
×
436

437
        cmd := execabs.Command(filepath.Join(binDir, "signtool.exe"),
×
438
                "sign", "/a", "/v", "/fd", "SHA256", "/f", r.certificate, "/p", r.password, appx)
×
439
        cmd.Stdout = os.Stdout
×
440
        cmd.Stderr = os.Stderr
×
441
        cmd.Stdin = os.Stdin
×
442
        return cmd.Run()
×
443
}
444

445
func (r *Releaser) validate() error {
×
446
        if r.os == "" {
×
447
                r.os = targetOS()
×
448
        }
×
449
        err := r.Packager.validate()
×
450
        if err != nil {
×
451
                return err
×
452
        }
×
453

454
        if util.IsMobile(r.os) || r.os == "windows" {
×
455
                if r.AppVersion == "" { // Here it is required, if provided then package validate will check format
×
456
                        return errors.New("missing required -appVersion parameter")
×
457
                }
×
458
                if r.AppBuild <= 0 {
×
459
                        return errors.New("missing required -appBuild parameter")
×
460
                }
×
461
        }
462
        if r.os == "windows" {
×
463
                if r.developer == "" {
×
464
                        return errors.New("missing required -developer parameter for windows release,\n" +
×
465
                                "use data from Partner Portal, format \"CN=Company, O=Company, L=City, S=State, C=Country\"")
×
466
                }
×
467
                if r.certificate == "" {
×
468
                        return errors.New("missing required -certificate parameter for windows release")
×
469
                }
×
470
                if r.password == "" {
×
471
                        return errors.New("missing required -password parameter for windows release")
×
472
                }
×
473
        }
474
        if util.IsAndroid(r.os) {
×
475
                if r.keyStore == "" {
×
476
                        return errors.New("missing required -keyStore parameter for android release")
×
477
                }
×
478
        } else if r.os == "darwin" {
×
479
                if r.certificate == "" {
×
480
                        r.certificate = "3rd Party Mac Developer Application"
×
481
                }
×
482
                if r.profile == "" {
×
483
                        return errors.New("missing required -profile parameter for macOS release")
×
484
                }
×
485
                if r.category == "" {
×
486
                        return errors.New("missing required -category parameter for macOS release")
×
487
                } else if !isValidMacOSCategory(r.category) {
×
488
                        return errors.New("category does not match one of the supported list: " +
×
489
                                strings.Join(macAppStoreCategories, ", "))
×
490
                }
×
491
        } else if r.os == "ios" {
×
492
                if r.certificate == "" {
×
493
                        r.certificate = "Apple Distribution"
×
494
                }
×
495
                if r.profile == "" {
×
496
                        return errors.New("missing required -profile parameter for iOS release")
×
497
                }
×
498
        }
499
        return nil
×
500
}
501

502
func (r *Releaser) writeEntitlements(tmpl *template.Template, entitlementData interface{}) (cleanup func(), err error) {
×
503
        entitlementPath := filepath.Join(r.dir, "entitlements.plist")
×
504
        entitlements, err := os.Create(entitlementPath)
×
505
        if err != nil {
×
506
                return nil, err
×
507
        }
×
508
        defer func() {
×
509
                if r := entitlements.Close(); r != nil && err == nil {
×
510
                        err = r
×
511
                }
×
512
        }()
513

514
        if err := tmpl.Execute(entitlements, entitlementData); err != nil {
×
515
                return nil, err
×
516
        }
×
517
        return func() {
×
518
                _ = os.Remove(entitlementPath)
×
519
        }, nil
×
520
}
521

522
func (r *Releaser) zipAlign(path string) error {
×
523
        unaligned := filepath.Join(filepath.Dir(path), "unaligned.apk")
×
524
        err := os.Rename(path, unaligned)
×
525
        if err != nil {
×
526
                return nil
×
527
        }
×
528

529
        cmd := filepath.Join(util.AndroidBuildToolsPath(), "zipalign")
×
530
        err = execabs.Command(cmd, "4", unaligned, path).Run()
×
531
        if err != nil {
×
532
                _ = os.Rename(path, unaligned) // ignore error, return previous
×
533
                return err
×
534
        }
×
535
        return os.Remove(unaligned)
×
536
}
537

538
func findWindowsSDKBin() (string, error) {
×
539
        inPath, err := execabs.LookPath("makeappx.exe")
×
540
        if err == nil {
×
541
                return inPath, nil
×
542
        }
×
543

544
        matches, err := filepath.Glob("C:\\Program Files (x86)\\Windows Kits\\*\\bin\\*\\*\\makeappx.exe")
×
545
        if err != nil || len(matches) == 0 {
×
546
                return "", errors.New("failed to look up standard locations for makeappx.exe")
×
547
        }
×
548

549
        return filepath.Dir(matches[0]), nil
×
550
}
551

552
func isValidMacOSCategory(in string) bool {
6✔
553
        found := false
6✔
554
        for _, cat := range macAppStoreCategories {
176✔
555
                if cat == strings.ToLower(in) {
173✔
556
                        found = true
3✔
557
                        break
3✔
558
                }
559
        }
560
        return found
6✔
561
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2024 Coveralls, Inc