Joist is a fast and simple CI platform for products built with Go

We started thinking about CI systems when we noticed how much quicker it was to run tests locally on a newish laptop, rather than this CI system we're paying several thousand dollars a month for. This took us down a rabbit hole: why are CI systems so expensive and so slow? The hardware out there is ridiculously fast, why can't I access it? Why do I even need to run the entire pipeline on the server? Can't I use a fraction of the machine i'm already running on?

You might've felt some of the same frustrations. YAML, unfamiliar and unclear configuration forcing you back to the docs, systems that are hostile to debug, high costs, and long waits. We wanted to see if we could do something better. We wanted to work in Go, the language we're already using. We didn't want to need to rely on opaque error messages, we don't want to RTFM (sorry!), we wanted to be able to SSH directly into the machine to inspect it's state. We also realized that we wanted a system that helped us maintain it's environment; that didn't force us to use Docker, and didn't force us to think through port-mapping, volumes, and the other components needed for a docker-compose setup.

Joist is for teams who're too busy building new product to be fighting their CI system. It's for teams who want their code up to production as quickly as possible, and who want their CI system to feel like an extension of their development environment. If you're small enough where your pipelines are still wicked fast and you fit into Github's free tier, this is probably not for you. If you're huge, and require a eye-crossing maze of systems and complex dependencies, this might also not be for you. However, if you're one of the teams who're sandwiched in the middle, who needs an intuitive, fast, and cheap CI system, you're the team we're making this product for.

Here's why we think you'll like it.

It's fast.

Joist runs on new dedicated hardware. We set up, run, manage, and monitor these systems ourselves. This means that you get great hardware for a cost much lower than the big cloud providers. Our smallest machine is already a beast; it has 6 cores / 12 threads, 64GB RAM, and ships with a 1TB NVME SSD drive. Even without the additional tech we've built, the boost your pipelines get from the raw horse power available makes huge impact.

By only supporting Go, Joist - by default - maintains your package and build caches for you. This results in faster builds and faster lints. Since it's on a dedicated machine, it also means that these caches sit next to, or near to, your code. No downloading zip files and extracting huge build folders. No worrying about invalidating the cache every month. Your cached builds are there, exactly where they need to be, as soon as your pipeline starts.

Inside Joist environments, we pre-build and pre-install as much as possible. This means that the time from starting a pipeline, and actually running your code is tiny. Also, since we prefer not to use Docker, most services are booted quickly. No updating package caches, nor installing package, not running configuration. The first thing your system does after it boots, is start executing your code.

Benchmarks can be deceptive and are littered with far too many "it depends" to give an authoritive answer on how much quicker your pipelines will run. The one thing you can be sure of, however: your code will run on new, dedicated hardware. Caches are local or nearly local, and we ensure package and build caches are shared - uncompressed - between pipelines. It's very likely to be much faster. On a codebase we had at hand, here's what the difference looked like without any additional changes.

Benchmark results

Far more power, far less cost.

Joist uses simple monthly billing. You tell us how many servers you want, you get those servers. If you want more, you order them. No surprise monthly fees, no huge invoices after a busy month. We're not a fan of the limitless pay-per-minute model. We make money by charging a margin per server. This pays for management, maintenance, support, and new features for the system. Our goal is to provide the convenience of a cloud service, with the pricing struture of DIY.

The cloud giants offer a great suite of services, but you pay a price for what you get. We've found that architecting our system to not rely on cloud services, means that we can a far better price-performance ratio.

The mix of fast, dedicated hardware and local-mode are a great mix. Much compute can be offloaded to the machines your team are using, and the remaining work will be performed - faster than it would otherwise - on the servers you have available.

Here's how our pricing looks.

Pricing

It's easy to use.

You write your Joist pipelines in Go. We write a lot of Go, shouldn't our pipelines be in the same language? No wheel needs to be re-invented; you're already familiar with the language, it's syntax, how to structure your code, packages, conditions, loops. You already have a language server installed on your machine. Why make it harder?

The laptops many devs use today are nuts. Shouldn't we make use of these? What if we could run parts of our pipeline on these? This might be linting, generating files, checking for vulnerabilities. Why does this need to be run remotely? Allocate your server's resources fully to the hard stuff; whether that's long-running tests, or deploys requiring sensitive secrets. Using Joist, one can also launch pipelines ad-hoc. Without needing to go through a whole version-control-cycle. We've found this helpful in creating new pipelines as well as making fixes for existing ones.

We don't want Docker as default. Docker has become the de-facto package manager for many CI systems. However, Docker comes with it's own complexities. Containers run in isolation; so sharing networks, files, and managing processes as well as debugging becomes complex, there are some good reasons to have this, but we don't want it. Our take on this: you can use Docker if you'd like, but we've made it easy to choose precise versions of packages as well as configure services without needing Docker. All packages and services run inside the system you create. This means that you can see and touch the files. Access various services directly on the port they're listening on, and manage processes as you would on your local machine. To achieve this we leverage the amazing work done by the Nix team, who've built an incredible system that helps build packages easily, and has amassed a package repository of more than 100,000 packages. Take a close look at the `EnvSpec` defined below, it'll help you see what we mean.

Here's how a pipeline looks.

package main

import (
    "os"

    "github.com/joistci/joist-go"
    "github.com/joistci/joist-go/api"
)

func main() {
    // When running, your code will run with several env vars set. These include:
    // - The repo name
    // - The event, push, pull_request, etc
    // - The branch name
    // - The commit SHA
    //
    // You can use these to determine what to do. I.e. you like to run certain
    // pipelines on certain events? Certain branches? A combination of the two?
    pipeline, err := joist.NewPipelineFromEnv()
    if err != nil {
        fmt.Fprintf(os.Stderr, "error creating pipeline: %s", err)
        os.Exit(1)
    }

    // This is the entrypoint to your pipeline. It takes a function that
    // returns a joist.ReturnStatus. If the function returns a non-ok status,
    // the pipeline will be marked as failed. This gives you the ability to
    // easily choose how your pipeline behaves in the event of an error.
    err = pipeline.Run(func(p *joist.Pipeline) joist.ReturnStatus {
        if checkResult := runChecks(p); !checkResult.IsOk() {
            return checkResult
        }

        if testResult := runTests(p); !testResult.IsOk() {
            return testResult
        }

        return joist.Ok()
    })
    if err != nil {
        fmt.Fprintf(os.Stderr, "error running pipeline: %s", err)
        os.Exit(1)
    }
}

func runChecks(pipeline *joist.Pipeline) joist.ReturnStatus {
    // Each step is tied to a "clean" system; a system that is set up
    // completely fresh for your step. You define exactly the packages you
    // need.
    spec := &api.EnvSpec{
        Packages: []api.Package{
            {Name: "go", Version: "1.20.3"},
            {Name: "golangci-lint", Version: "1.61.0"},
            {Name: "just", Version: "1.35.0"},
        },
    }

    // A step is started like so. A step represents a grouping of operations
    // that succeed or fail together. For example, tests, linting, building,
    // deploying, etc.
    return pipeline.Step("checks", spec, func(step *joist.Step) joist.ReturnStatus {
        // Running `step.Exec` runs the command in the environment. The first
        // parameter is the name of the command, and the second is the command
        // itself. Joist supports single-line commands like below, as well as
        // multi-line cmmands that look more like a bash script.
        version, err := step.Exec("check version", "go version")
        if err != nil {
            return joist.Failf("unable to execute version command: %s", err)
        }

        if version.ExitStatus != 0 || version.Stdout != "go version go1.20.3 linux/amd64\n" {
            return joist.Failf(
                "go version check failed: status: %d, version: %s",
                version.ExitStatus,
                version.Stdout,
            )
        }

        lint, err := step.Exec("run linter", "golangci-lint run ./...")
        if err != nil {
            return joist.Failf("unable to execute linter command: %s", err)
        }

        if lint.ExitStatus != 0 {
            return joist.Failf("linter failed: status: %d", lint.ExitStatus)
        }

        return joist.Ok()
    })
}

func runTests(p *joist.Pipeline) joist.ReturnStatus {
    // Here's a slightly more complex environment. Here, we have environment
    // variables, globally defined for all commands executed within this step,
    // as well as two services. One for Redis and one for Postgres. These
    // services will be installed, and (if run_on_startup is set) will be
    // booted and ready to go as soon as you start your step.
    spec := &api.EnvSpec{
        Packages: []api.Package{
            {Name: "go", Version: "1.20.3"},
            {Name: "just", Version: "1.35.0"},
        },
        EnvVars: []api.EnvVar{
            {Name: "PG_PASSWORD", Value: "changeme"},
            {Name: "PG_USER", Value: "postgres"},
            {Name: "PG_HOST", Value: "localhost"},
            {Name: "PG_PORT", Value: "5432"},
        },
        Services: []api.Service{
            {Name: "postgresql", Version: "15.4", Config: map[string]any{
                "run_on_startup":     true,
                "superuser_password": "changeme",
            }},
            {Name: "redis", Version: "6.0.3", Config: map[string]any{
                "run_on_startup": true,
            }},
        },
    }

    return p.Step("tests", spec, func(step *joist.Step) joist.ReturnStatus {
        res, err := step.Exec("run tests", "go test ./... -v -race -cover")
        if err != nil {
            return joist.Failf("unable to execute tests command: %s", err)
        }

        if res.ExitStatus != 0 {
            return joist.Failf("tests failed: status: %d")
        }

        return joist.Ok()
    })
}

Here's where we need help.

We're almost done with the an alpha version of Joist, and we're looking for feedback in two ways.

  1. We're after general feedback. What do you think is missing? What doesn't help? What's confusing? What's great? What's missing from your workflow that would make it a no-brainer?
  2. We're after a small group of testers. As part of testing, you'll help provide feedback and battle-test the product. In return, you'll get 3 months of a V0-A server, and direct support through integration. If you're a small-mid sized company with existing CI workloads, and need more from your CI system, this might be a great match.

If you fit into either of these groups, get in touch. Even if you have a half-formed idea, thought, or comment. We're happy and keen to listen.
- Dan