# Building a CI pipeline for a Go CLI application with Dagger

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"
}
}
}


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 & {

env: {
if client.env.GITHUB_TOKEN != _|_ {
GITHUB_TOKEN: client.env.GITHUB_TOKEN
}
}
}


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 & {

package: "."

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

ldflags: "-s -w"

env: {
CGO_ENABLED: "0"
}
}
}


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:     _
}
}
}


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": _
}
}


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
include: [
]
}

// ...
}


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)"
}
}

// ...
}


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
}


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


### 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"
flags: {
"-U":         true
"--no-cache": true
}
}
},
bash.#Run & {
script: contents: """
chmod +x /usr/local/bin/gh
"""
},
]
}
}


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
}
}
}
}


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

release: github.#Release & {
artifacts: package.output

tag: client.env.GITHUB_REF_NAME

env: {
if client.env.GITHUB_TOKEN != _|_ {
GITHUB_TOKEN: client.env.GITHUB_TOKEN
}
}
}


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.