DEV Community

Márk Sági-Kazár
Márk Sági-Kazár

Posted on • Originally published at sagikazarmark.hu

Building a CI pipeline for a Go CLI application with Dagger

Originally published at https://sagikazarmark.hu.


In my previous post I explained the basic steps of building a CI pipeline for a Go library with Dagger. Let's move onto the next level: Go CLI applications.

TL;DR: Check out this repository for a complete example.

Important: As in the previous post, I'm going to focus on Go CLI specific details and will not explain Dagger basic concepts. Please check out the documentation for an introduction into Dagger and my previous post for Go related examples.

Recap

In my previous post I defined four common categories of CI steps:

  • Running various builds and tests in a build matrix (eg. for multiple Go versions)
  • Static analysis and linters
  • Publishing analysis results (eg. code coverage) to a third-party service
  • Publishing artifacts

One could argue that the last two categories are actually the same, but they usually serve different audiences, so I prefer listing them separately.

Most steps of the Go library use case fit into the first three categories. While they might still be present in the pipeline for a CLI tool, the most important step for this use case is building and publishing artifacts.

Prior art: GoReleaser

GoReleaser is the standard tool in the Go ecosystem for building and publishing Go CLI applications. It's capable of building, packaging and publishing CLI applications in several package formats to several artifact stores.

GoReleaser itself is a CLI application (written in Go) which makes it a good candidate for being integrated into a Dagger pipeline.

To do that, create a new CUE file for the action definition (in ci/goreleaser/release.cue):

// Release Go binaries using GoReleaser
#Release: {
    // Source code
    source: dagger.#FS

    // GoReleaser version
    version: *"1.10.1" | string

    _image: docker.#Pull & {
        source: "index.docker.io/goreleaser/goreleaser:v\(version)"
    }

    go.#Container & {
        name: "goreleaser"
        entrypoint: []
        "source": source
        input:    _image.output
        command: {
            name: "goreleaser"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly to other Go related actions, we use the go.#Container definition to make use of all the caches and mounts set in it.

Next, create a new action in your Dagger plan:

release: goreleaser.#Release & {
    source: client.filesystem["."].read.contents

    env: {
        if client.env.GITHUB_TOKEN != _|_ {
            GITHUB_TOKEN: client.env.GITHUB_TOKEN
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, add and commit all pending changes, tag a new version and run GITHUB_TOKEN=<TOKEN> dagger do release to publish a new release to GitHub.

Custom release pipeline

Looking closer at what GoReleaser does under the hood, it's possible to do with a "pure" Dagger pipeline:

  • Build the application for multiple targets/platforms (build matrix)
  • Package the binaries and additional files (eg. README.md) into archives/distro packages
  • Push the resulting packages to artifact stores (eg. GitHub Releases)

Build the application

Let's start with the build step. Similarly to running tests for a library, you can use Cue templating to setup a build matrix to support multiple OSes and architectures:

build: {
    "linux/amd64":   _
    "darwin/amd64":  _
    "windows/amd64": _

    [platform=string]: go.#Build & {
        source: client.filesystem["."].read.contents

        package: "."

        os:   strings.Split(platform, "/")[0]
        arch: strings.Split(platform, "/")[1]

        ldflags: "-s -w"

        env: {
            CGO_ENABLED: "0"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You can build the application (all variants) with dagger do build.

Packaging

The next step is packaging binaries and other files (eg. README.md) into archives. Since there is no builtin archive action in Dagger Universe, we have to build our own.

First we need to create an image with the necessary tools (in ci/archive/image.cue):

// Build an archive base image
#Image: {
    alpine.#Build & {
        packages: {
            bash:      _
            coreutils: _
            p7zip:     _
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we need a definition for an action that creates an archive from a list of files. The action has to be able to create .tar.gz files for Linux and Darwin, .zip files for Windows:

// Create a new archive
#Create: {
    // Source files for the archive
    source: dagger.#FS

    // Archive name
    name: string

    _image: #Image

    _sourcePath: "/src"

    bash.#Run & {
        input: _image.output

        script: contents: """
case "\(name)" in
    *.tar.gz | *.tgz)
        tar -czvf \(name) *
        ;;

    *.zip)
        7z a \(name) *
        ;;

    *)
        echo "Unsupported archive type"
        exit 1
        ;;
esac

mkdir -p /result
mv \(name) /result
"""

        workdir: _sourcePath
        mounts: {
            "source": {
                dest:     _sourcePath
                contents: source
            }
        }

        export: directories: "/result": _
    }
}
Enter fullscreen mode Exit fullscreen mode

As a next step, let's add package building to our Dagger plan!

Binary archives often contain additional files, like README.md, so prepare those files first:

package: {
    _files: core.#Copy & {
        input:    dagger.#Scratch
        contents: client.filesystem["."].read.contents
        include: [
            "README.md",
            "LICENSE",
        ]
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Create the actual archives using the archive.#Create we defined earlier. The input for each archive is the binary from the build action and the additional files we prepared earlier:

package: {
    // ...

    _archives: {
        "linux/amd64":   _
        "darwin/amd64":  _
        "windows/amd64": _

        [platform=string]: archive.#Create & {
            _source: core.#Merge & {
                inputs: [
                    _files.output,
                    if platform == "darwin/amd64" {
                        build."darwin/amd64".output
                    },
                    if platform == "linux/amd64" {
                        build."linux/amd64".output
                    },
                    if platform == "windows/amd64" {
                        build."windows/amd64".output
                    },
                ]
            }

            source: _source.output

            _os:   strings.Split(platform, "/")[0]
            _arch: strings.Split(platform, "/")[1]
            _type: string | *"tar.gz"
            if _os == "windows" {
                _type: "zip"
            }

            name: "dagger-go-cli_\(_os)_\(_arch).\(_type)"
        }
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, define the output of the package action:

package: {
    // ...

    _packages: core.#Merge & {
        inputs: [
            package._archives."linux/amd64".export.directories."/result",
            package._archives."darwin/amd64".export.directories."/result",
            package._archives."windows/amd64".export.directories."/result",
        ]
    }

    output: _packages.output
}
Enter fullscreen mode Exit fullscreen mode

package.output will contain all three archives for the three supported platforms. You can build them running dagger do package.

In case you want to check the content of the resulting archives, you can write them to the filesystem by adding the following to the Dagger plan:

client: filesystem: "./_build": write: contents: actions.package.output
Enter fullscreen mode Exit fullscreen mode

Publishing the release on GitHub

The final step is creating a release on GitHub and uploading the archives. The easiest way to do that is using the GitHub CLI.

First (as usual), we need to create an image definition (in ci/github/image.cue):

#Image: {
    version: string | *"2.13.0"

    docker.#Build & {
        steps: [
            docker.#Pull & {
                source: "index.docker.io/alpine:3.15.0@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300"
            },
            docker.#Run & {
                command: {
                    name: "apk"
                    args: ["add", "bash", "curl", "git"]
                    flags: {
                        "-U":         true
                        "--no-cache": true
                    }
                }
            },
            bash.#Run & {
                script: contents: """
apk add curl tar
curl -L https://github.com/cli/cli/releases/download/v\(version)/gh_\(version)_linux_amd64.tar.gz | tar -zOxf - gh_\(version)_linux_amd64/bin/gh > /usr/local/bin/gh
chmod +x /usr/local/bin/gh
"""
            },
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we can create the definition for the release action (in ci/github/release.cue):

#Release: {
    // Source files
    source: dagger.#FS

    // Artifact files
    artifacts: dagger.#FS

    // Create release from this specific tag
    tag: string

    _image: #Image

    _sourcePath:   "/src"
    _artifactPath: "/artifacts"

    bash.#Run & {
        input: *_image.output | docker.#Image
        script: contents: "gh release create --title '\(tag)' \(tag) /artifacts/*"
        workdir: _sourcePath
        mounts: {
            "source": {
                dest:     _sourcePath
                contents: source
            }
            "artifacts": {
                dest:     _artifactPath
                contents: artifacts
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can integrate the new action into your Dagger plan:

release: github.#Release & {
    source:    client.filesystem["."].read.contents
    artifacts: package.output

    tag: client.env.GITHUB_REF_NAME

    env: {
        if client.env.GITHUB_TOKEN != _|_ {
            GITHUB_TOKEN: client.env.GITHUB_TOKEN
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You can run GITHUB_REF_NAME=SOME_TAG GITHUB_TOKEN=<TOKEN> dagger do release locally to create a new release or run it on GitHub Actions.

Conclusion

GoReleaser is the standard tool for publishing CLI applications and it can be easily integrated into a Dagger pipeline. Based on the complexity of the pipeline, it might not worth the effort compared to just running GoReleaser on GitHub Actions, but Dagger can still be useful when it comes to debugging pipelines.

Alternatively, you can choose to build your pipeline without GoReleaser which gives you more control, but requires (a lot) more work that may not be worth the investment for small projects.

Personally, I'm looking forward to see which pattern becomes more popular in the Dagger community: using ready-made tools (like GoReleaser, MegaLinter, etc) integrated into Dagger or writing "pure" Dagger pipelines using lower-level tools.

Make sure to check out the complete example for more details.

Discussion (0)