Learning Go by examples: part 8 - Automatically cross-compile & release your Go app

Aurélie Vache - Sep 1 '21 - - Dev Community

In previous articles we created an HTTP REST API server, a CLI, a Bot for Discord, a gRPC app ... and even a game for Nintendo Game Boy Advance.

As we have already seen, Golang can be used for several of type of applications, but you know, I love creating CLI apps & tools, I love DevOps philosophy and I love Gophers. So in this article we'll create a little tool, with few Go best practices, and automatically generate cross-platform executable binaries and create GitHub releases through GitHub actions.

Ready?

Initialization

First, create our new repository in GitHub (in order to share and open-source it).

For that, I logged in GitHub website, clicked on the repositories link, click on "New" green button and then I created a new repository called gophersay.

Now, in your local computer, git clone this new repository where you want:



$ git clone https://github.com/scraly/gophersay.git
$ cd gophersay


Enter fullscreen mode Exit fullscreen mode

Now, we have to initialize Go modules (dependency management):



$ go mod init github.com/scraly/gophersay
go: creating new go.mod: module github.com/scraly/gophersay


Enter fullscreen mode Exit fullscreen mode

This will create a go.mod file like this:



module github.com/scraly/gophersay

go 1.16


Enter fullscreen mode Exit fullscreen mode

Before starting to code our Desktop application, as good practices, we will create a simple code organization.

Create the following folders organization:



.
├── bin
├── README.md
└── go.mod


Enter fullscreen mode Exit fullscreen mode

That's it? Yes, the rest of our code organization will be created shortly ;-).

Let's create our app

Fox Gopher

What do we want?

Do you know "cowsay" application? It's a simple app that display your text said by a cow.

cowsay

I love cow, but I love more Gophers, so we want to create a "cowsay" version with a Gopher instead of a cow.
The program will display a text written by the user and a Gopher, in ASCII, that say the text.

This app may seem useless but you will see, it will allow us to see some good practices ;-).

The first thing to do is to retrieve Gophers in ASCII format I created and put them in a new gophers folder. You need to have a folder like this:



├── gophers
│   ├── gopher0.txt
│   ├── gopher1.txt
│   ├── gopher2.txt
│   └── gopher3.txt


Enter fullscreen mode Exit fullscreen mode

Create a main.go file.

First, we initialize the package, called main, and all dependencies/librairies we need to import:



package main

import (
    "fmt"
    "log"
    "math/rand"
    "os"
    "strconv"
    "strings"
    "time"

    "embed"
)


Enter fullscreen mode Exit fullscreen mode

Then, we initialize our variable:



// Hey, I want to embed "gophers" folder in the executable binary
// Use embed go 1.16 new feature (for embed gophers static files)
//go:embed gophers
var embedGopherFiles embed.FS


Enter fullscreen mode Exit fullscreen mode

Thanks to awesome embed feature included since Go 1.16 version, we tell that we embed gophers/ folder in the variable embedGopherFiles.

Let's create the main() function:



func main() {

    // Display usage/help message
    if len(os.Args) == 1 || (len(os.Args) == 2 && os.Args[1] == "-h") || (len(os.Args) == 2 && os.Args[1] == "--help") {
        usage := "GopherSay is inspired by Cowsay program.\nGopherSay allow you to display a message said by a cute random Gopher.\n\nUsage:\n   gophersay MESSAGE\n\nExample:\n   gophersay hello Gopher lovers"

        fmt.Println(usage)
        return
    } else if len(os.Args) > 1 {

        message := strings.Join(os.Args[1:], " ")
        nbChar := len(message)

        line := " "
        for i := 0; i <= nbChar; i++ {
            line += "-"
        }

        fmt.Println(line)
        fmt.Println("< " + message + " >")
        fmt.Println(line)
        fmt.Println("        \\")
        fmt.Println("         \\")

        // Generate a random integer depending on get the number of ascii files
        rand.Seed(time.Now().UnixNano())
        randInt := rand.Intn(getNbOfGopherFiles() - 1)

        // Display random gopher ASCII embed files
        fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")
        if err != nil {
            log.Fatal("Error during read gopher ascii file", err)
        }
        fmt.Println(string(fileData))
    }
}


Enter fullscreen mode Exit fullscreen mode

It's time to explain the main() function step by step.

First, if the user execute our app/tool without argument, or with "-h" option and or "--help" option, we display an usage/a help message:



    // Display usage/help message
    if len(os.Args) == 1 || (len(os.Args) == 2 && os.Args[1] == "-h") || (len(os.Args) == 2 && os.Args[1] == "--help") {
        usage := "GopherSay is inspired by Cowsay program.\nGopherSay allow you to display a message said by a cute random Gopher.\n\nUsage:\n   gophersay MESSAGE\n\nExample:\n   gophersay hello Gopher lovers"

        fmt.Println(usage)
        return
    }


Enter fullscreen mode Exit fullscreen mode

Then, if user execute the gophersay app with an argument, a text, we define a variable message that retrieve all arguments and a variable with the number of characters of the message.
We print out this message surrounded by "bubble", like "cowsay" program does:



else if len(os.Args) > 1 {

        message := strings.Join(os.Args[1:], " ")
        nbChar := len(message)

        line := " "
        for i := 0; i <= nbChar; i++ {
            line += "-"
        }

        fmt.Println(line)
        fmt.Println("< " + message + " >")
        fmt.Println(line)
        fmt.Println("        \\")
        fmt.Println("         \\")


Enter fullscreen mode Exit fullscreen mode

After that, we generate a random integer between 0 and the number of gopher files we have -1 (4-1 at this time but I plan to add more):



        // Generate a random integer depending on get the number of ascii files
        rand.Seed(time.Now().UnixNano())
        randInt := rand.Intn(getNbOfGopherFiles() - 1)


Enter fullscreen mode Exit fullscreen mode

Wait... why do we execute rand.Seed() function?

rand.Intn(int) returns a non negative pseudo-random number in [0,n]. It's cool, but … it produces a deterministic sequence of values!
So the solution, in order to have "real" random number is to use rand.Seed() in order to initialize the default source.

crypto/rand

Let's go back to our code, we then want to display our cute ASCII Gopher:



        // Display random gopher ASCII embed files
        fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")
        if err != nil {
            log.Fatal("Error during read gopher ascii file", err)
        }
        fmt.Println(string(fileData))


Enter fullscreen mode Exit fullscreen mode

And finally, create the function that return the number of ASCII Gopher image files:



func getNbOfGopherFiles() int {

    files, err := embedGopherFiles.ReadDir("gophers")
    if err != nil {
        log.Fatal("Error during reading gophers folder", err)
    }

    nbOfFiles := 0
    for _, _ = range files {
        nbOfFiles++
    }

    return nbOfFiles
}


Enter fullscreen mode Exit fullscreen mode

OK, but what is this famous embed??

If we package only our main.go file in an executable binary, when we will execute it, we'll have a problem because "gophers/" folder not exists in your computer.

Before Go version 1.16, there were several solutions but not as easy as the new embed package.

The new embed package provides access to files embedded in the program during compilation using the new //go:embed directive.

The new //go:embed directive allow to embed static files and folders into application binary at compile-time without using an external tool.

In order to use it, first we have to declare a variable for the embedded content. In our example we embed our gophers/ folder:



//go:embed gophers
var embedGopherFiles embed.FS


Enter fullscreen mode Exit fullscreen mode

Then, we can read one file in this folder:



fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")


Enter fullscreen mode Exit fullscreen mode

And retrieve a list of the files in this folder:



files, err := embedGopherFiles.ReadDir("gophers")


Enter fullscreen mode Exit fullscreen mode

You can also embed a file directly:



//go:embed gophers/gopher0.txt
var myFile string


Enter fullscreen mode Exit fullscreen mode

/!\ If the embed pattern names a folder, all files are embedded (recursively), except the files with names beginning with "." or "_".
If you want to embed them, you need to specify the folder like this: myfolder/*

Awesome!

Test it!

After code explanation, it's time to test our little app!



$ go run main.go
GopherSay is inspired by Cowsay program.
GopherSay allow you to display a message said by a cute random Gopher.

Usage:
   gophersay MESSAGE

Example:
   gophersay hello Gopher lovers


Enter fullscreen mode Exit fullscreen mode


$ go run main.go --help
GopherSay is inspired by Cowsay program.
GopherSay allow you to display a message said by a cute random Gopher.

Usage:
   gophersay MESSAGE

Example:
   gophersay hello Gopher lovers


Enter fullscreen mode Exit fullscreen mode

Cool, we have our usage message.



$ go run main.go Hello Gopher lovers!


Enter fullscreen mode Exit fullscreen mode

gophersay

Yeah! Our text is said by one of our cute ASCII Gophers!

Build it!

Your application is now ready, you can build it.

In previous articles, we used Taskfile in order to automate our common tasks.

I created a Taskfile.yaml file:



version: "3"

tasks:
    run: 
        desc: Run the app
        cmds:
        - GOFLAGS=-mod=mod go run main.go

    build:
        desc: Build the app
        cmds:
        - GOFLAGS=-mod=mod go build -o bin/gophersay main.go 

    clean:
        desc: Build the app
        cmds:
        - rm -rf dist 


Enter fullscreen mode Exit fullscreen mode

So, now we can build our app:



$ task build
task: [build] GOFLAGS=-mod=mod go build -o bin/gophersay main.go


Enter fullscreen mode Exit fullscreen mode

But... the executable binary is only for our environment, our OS, our platform, and I want to share my gophersay worldwide so it's time to find a way to easily cross-compile our app!

GoReleaser

GoReleaser

With GoReleaser, it's possible to:

  • Cross-compile Go project
  • Release to GitHub, GitLab and Gitea
  • Create Docker images and manifests
  • Create Linux packages and Homebrew taps
  • ...

Oh, come on, it's exactly what do we want!

First, we need to install GoReleaser.

For MacOS:



$ brew install goreleaser/tap/goreleaser


Enter fullscreen mode Exit fullscreen mode

Run the init command to create a .goreleaser.yml configuration file:



$ goreleaser init
   • Generating .goreleaser.yml file
   • config created; please edit accordingly to your needs file=.goreleaser.yml


Enter fullscreen mode Exit fullscreen mode

Let's watch this new generated file:



# This is an example .goreleaser.yml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
before:
  hooks:
    # You may remove this if you don't use go modules.
    - go mod tidy
    # you may remove this if you don't need go generate
    - go generate ./...
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - windows
      - darwin
archives:
  - replacements:
      darwin: Darwin
      linux: Linux
      windows: Windows
      386: i386
      amd64: x86_64
checksum:
  name_template: 'checksums.txt'
snapshot:
  name_template: "{{ incpatch .Version }}-next"
changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'


Enter fullscreen mode Exit fullscreen mode

It's pretty cool. Because we don't use go generate in our application, we can remove the - go generate ./... line ;-).

Let's run a "local-only" release to generate a release of our Go app locally:



$ goreleaser release --snapshot --skip-publish --rm-dist


Enter fullscreen mode Exit fullscreen mode

/!\ Don't forget to call this goreleaser release command with --rm-dist option or you can execute task clean target in order to remove dist/ folder. If not, you'll have an issue because this folder need to be empty ^^.

If we take a look into new dist/ generated folder, we can see that GoReleaser generate, for us, cross-platform executable binaries, and checksum:



dist
├── checksums.txt
├── config.yaml
├── gophersay_0.0.1-next_Darwin_arm64.tar.gz
├── gophersay_0.0.1-next_Darwin_x86_64.tar.gz
├── gophersay_0.0.1-next_Linux_arm64.tar.gz
├── gophersay_0.0.1-next_Linux_i386.tar.gz
├── gophersay_0.0.1-next_Linux_x86_64.tar.gz
├── gophersay_0.0.1-next_Windows_i386.tar.gz
├── gophersay_0.0.1-next_Windows_x86_64.tar.gz
├── gophersay_darwin_amd64
│   └── gophersay
├── gophersay_darwin_arm64
│   └── gophersay
├── gophersay_linux_386
│   └── gophersay
├── gophersay_linux_amd64
│   └── gophersay
├── gophersay_linux_arm64
│   └── gophersay
├── gophersay_windows_386
│   └── gophersay.exe
└── gophersay_windows_amd64
    └── gophersay.exe


Enter fullscreen mode Exit fullscreen mode

It's perfect!

When GitHub Action meet GoReleaser...

And what about generate a new release automatically?

Now, the next step is to execute GoReleaser, and publish a new associated Release in GitHub everytime we tag a new version of our application in our Git repository.

Let's do this!

Our Git repository is hosted in GitHub so we will use GitHub Actions for our CI (Continuous Integration) pipeline.

Create our workflow:



$ mkdir .github/workflows
$ cd .github/workflows


Enter fullscreen mode Exit fullscreen mode

Inside it, create goreleaser.yml file with this content:



name: goreleaser

on:
  push:
    tags:
      - '*'

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.16
      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v2
        with:
          distribution: goreleaser
          version: latest
          args: release --rm-dist
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


Enter fullscreen mode Exit fullscreen mode

This workflow contains one job that we'll checkout the repository, package our app with GoReleaser and generate a GitHub release.

/!\ In order to release to GitHub, GoReleaser need a valid GitHub token with the repo scope. Fortunately, GitHub automatically creates a GITHUB_TOKEN secret to use in a workflow.

After pushed your modification in the Git repository, now we can create a Git tag and push it:



$ git tag -a v1.0.0 -m "First release"

$ git push --tags
Énumération des objets: 1, fait.
Décompte des objets: 100% (1/1), fait.
Écriture des objets: 100% (1/1), 157 octets | 157.00 Kio/s, fait.
Total 1 (delta 0), réutilisés 0 (delta 0), réutilisés du pack 0
To https://github.com/scraly/gophersay.git
 * [new tag]         v1.0.0 -> v1.0.0


Enter fullscreen mode Exit fullscreen mode

Let's go in our GitHub repository, and click on "Actions" tab in order to watch running, failed and successfull workflows:

Gh actions

Perfect, our workflow successfully runned.

A new GitHub release have been automatically created:
GH release

GH release details

So now, each time I will update my app and create a Git tag and push it, automatically a new (GitHub) GH release will be created with cross-platform binaries :-).

Thanks

If you like this article/tutorial and the cute gophersay app, don't hesitate to add a star on GitHub :-)

GitHub logo scraly / gophersay

GopherSay allow you to display a message said by a cute random Gopher.

GopherSay


GitHub release
Code Status

About

Welcome in GopherSay!

GopherSay is inspired by Cowsay program.

GopherSay allow you to display a message said by a cute random Gopher.

Installation

For MacOS:

brew tap scraly/tools
brew install gophersay

Pre-requisites

Install Go in 1.16 version minimum.

Build the app

$ go build -o bin/gophersay main.go

or

$ task build

Run the app

$ ./bin/gophersay

or

$ task run

Test the app

$ ./bin/gophersay Hello Gopher lovers
 ---------------------
< Hello Gopher lovers! >
 ---------------------
        \
         \
                                          ,
                             (%..**,,,,,.& .,,.**
                            ,%,*..,,,. .** **.,*,,**./
                           ./*,,.. .**,.,..,.,**.**..(.
                      .   (@.,*%.**.,,**,.,,%*..**,*,/(
                  ,..../../&&%................%///#.,***.....
                   /....,..........................@*@%...,.,
                     .....................................,
                    &*   #@................................
                 &           ...............................
                #             .........................%       @
               /@     @,      ........................*          *
              *.,            @.......................@    /@@
             /....%        ..........................&
            /........%@@..............................%         %
           ,....................................................*   *   .%
           .........................@,,,,,,,@...................* @   *****#
          *........................@,,,,,,/&(...................  /. ****,**
         ........................@,,,,,,,,,,,, ................/ #/////( ,*,
         //.....................@,,,,,,,,,,,,#................., #//////////
      ...........................,@@@   /  @................../....**//////(
      ...,.........................@@      @.......................///*//*/(
         ...........................&@@@@@@..................   @///////////
         ,..................................................*   @///////&* *
         /..................................................    @/@..,(@.& (
         ,.................................................     @ @/ .(#   .
          ................................................,     @ ,.%(#,**
          .............................................../      @ . @////*/

Conclusion

As you have seen in this article and previous articles, it's possible to create multiple different applications in Go... and to automatize build and cross-compilation.
This time we didn't use Cobra, Viper and other frameworks because I wanted to show you we ca do a very small CLI without it :-).

All the code of our GopherSay app in Go is available in: https://github.com/scraly/gophersay

In the following articles we will create others kind/types of applications in Go.

Hope you'll like it.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .