Migrate From Travis CI to GitHub Actions

Recently, a colleague pointed out that I was still configuring Travis-CI on new GitHub repos and suggested I used GitHub Actions instead. I had given Actions the ol' five-minute test when it was still in beta, but ran into a few problems and gave up. After all, I’ve been a fan of Travis-CI for a while and I had enough new things to learn at the time.

Still, if GitHub Actions lives up to the hype, it would mean one less external service to worry about with all the usual concerns like downtime, access control, etc. In this post, I’m going to walk through migrating an existing repository to GitHub Actions and share what I learn as I go.

The Travis-CI Config

I’m going to use the Okta Maven Plugin for my repository. If you are a Java developer, it can get you set up with Okta in 30 seconds, check out the project’s readme. Even though I’m using a Java project, it’s possible to modify these steps for any project.

My original .travis.yml configures a simple matrix build. Matrix builds contain multiple jobs that run in parallel with different configurations. These can be as complex as you need; run a build on multiple operating systems, against different versions of tools, and with different environment variables. A matrix build that defines two operating systems, three environment variables, and two versions of Java would result in 12 individual builds (2 x 3 x 2).

My project expands into just two builds, one for each Java (8 and 11):

language: java

jdk: (1)
- openjdk8
- openjdk11

addons:
  apt:
    packages:
    - libxml2-utils (2)

before_install:
- source ./src/ci/before_install.sh (3)

# skip the Travis-CI install phase because Maven handles that directly
install:
- 'true'

script:
- "./src/ci/build.sh" (4)

after_success:
- bash <(curl -s https://codecov.io/bash) -f coverage/target/site/jacoco/jacoco.xml (5)
1 Build and test the project with Java 8 and 11
2 Optional, but provides a quick way to grab the version from a pom.xml
3 Runs a script to setup environment variables
4 Runs a build script
5 Uploads code coverage to codecov.io
I intentionally keep as much logic out of travis.yml as possible because this makes it easier to both troubleshoot build problems locally and migrate to a different service if/when needed.

My build scripts do have a few references to Travis-CI environment variables like TRAVIS_REPO_SLUG and TRAVIS_SECURE_ENV_VARS, so I’ll need to find a replacement for those too.

First Steps to GitHub Actions

The first step is to create a branch in your project. I called mine "github-actions-test":

git checkout -b github-actions-test

GitHub will look at all of the YAML files in the .github/workflows directory in your project. I named mine, build.yml.

Why do all of these services still use the yml extension? The yaml.org FAQ even recommends yaml! Is anyone out there still supporting DOS 8.3 file names? 😜

To keep this simple, I’m going to use a template for an Apache Maven projects on the first pass to make sure everything is working and then circle back and set up a matrix build with my custom build scripts.

name: Java CI

on: (1)
  push:
  pull_request:
  schedule:
    - cron: '0 0 * * 0' # weekly

jobs:
  build:
    runs-on: ubuntu-latest (2)

    steps:
      - uses: actions/checkout@v2
      - name: Set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8 (3)
      - name: Build with Maven
        run: ./mvnw -B verify (4)
1 Run the build on all branches, pull requests, and scheduled weekly
2 Target OS
3 Use Java 8
4 Run a command
Scheduled jobs are ONLY run against the "default" branch. I lost hours of my life trying to figure this out.

Test it out by adding the file and pushing the branch:

git add .github/workflows/build.yml
git commit -m "add github actions script"
git push origin github-actions-test

Navigate to the "Actions" tab of your GitHub repository to see the build, for example:

GitHub Actions tab screenshot

Of course, if you create a pull request the build status will be reported there as well.

My build was all green, so I’ll update the run attribute with my custom script:

run: source ./src/ci/before_install.sh && ./src/ci/build.sh
My before_install.sh script just sets environment variables, so it needs to be run in the same context block as my build.sh. I’ll cover a few other options for environment variables below.

Commit and push the changes again.

Woot! Another successful build! 🟢

Matrix Builds with GitHub Actions

Matrix builds are configured a little differently in Actions than Travis CI, and it took some head-scratching before I understood the differences between the two. With Travis CI the configuration is declarative, unlike GitHub Actions which uses an expression syntax for everything. Variables are defined in a matrix element which are then used in "expressions" throughout your configuration. To build against multiple versions of Java I needed to define strategy.matrix.java = [8, 11] and then use the expression ${{ matrix.java }} where I previously had hard coded "1.8":

name: Java CI

on:
  push:
  pull_request:
  schedule:
    - cron: '0 0 * * 0' # weekly

jobs:
  build:
    runs-on: ubuntu-latest
    name: Java ${{ matrix.java }} (3)
    strategy: (1)
      matrix:
        java: [8, 11] (2)

    steps:
      - uses: actions/checkout@v2
      - name: Set up JDK ${{ matrix.java }} (4)
        uses: actions/setup-java@v1
        with:
          java-version: ${{ matrix.java }} (5)
      - name: Build and Test
        run: source ./src/ci/before_install.sh && ./src/ci/build.sh
1 The strategy node defines the matrix options, similar to Travis CI. You can also define multiple operating systems here
2 Defines the versions of Java to support
3 Adds a user-friendly name; otherwise the default name will be "build (8)" and "build (11)"
4 Updates the display name to be user-friendly
5 Uses the matrix.java expression to set the version of java installed by the setup-java action

Once again, commit and push to your branch. Then, head over to the "Actions" tab on your GitHub project to see the matrix build result.

GitHub Actions matrix build screenshot

Add Other GitHub Actions

My original travis.yml included an "after success" step to upload code coverage data. This step simply executes a bash script:

after_success:
- bash <(curl -s https://codecov.io/bash) -f target/site/jacoco/jacoco.xml
I know, I’m not a fan of piping remote URLs to bash either.

The same command could be run directly with GitHub Actions too, but the GitHub Marketplace contains a whole host of third-party actions you can plug in into your build; a quick search for "Codecov" turned up what I was looking for!

Third-party actions use the same format as a GitHub Action, uses: <org>/<repo>@<tag>. For Codecov the usage looks like this:

- uses: codecov/codecov-action@v1
  with:
   file: target/site/jacoco/jacoco.xml
This action does the same thing as the original bash script under the covers, the syntax is just more declarative.

Replace Travis CI Environment Variables

I mentioned before that my bash scripts used a few TRAVIS_* environment variables. They also default to reasonable values where possible, which allows for running the script locally, or via GitHub Actions. To keep things focused in this post, I’ll walk through setting the Travis CI environment variables and tease the implementation-specific bits out of my build in a future post.

There are two ways to set environment variables with GitHub Actions: declare them directly in the YAML file or use a special syntax to output them to the console.

Declare them globally for your whole job:

jobs:
  build:
    env:
      SOME_GLOBAL_ENV_VAR_NAME: a-value

Or scoped to the context of a single step:

steps:
  - name: scope to a single step
    env:
      SOME_ENV_VAR_NAME: your-value
    run: echo "my env var: ${SOME_ENV_VAR_NAME}"

You can also write a script to output a specific format: ::set-env name=<var-name>::<value>. In practice, that looks like this:

run: echo "::set-env name=SOME_ENV_VAR_NAME::your-value"

The GitHub Actions context variables and Travis CI environment variables don’t always line up one-to-one, but I was able to find the Actions equivalent for the following:

  • TRAVIS_BRANCH - The branch/tag the build is running against

  • TRAVIS_EVENT_TYPE - For scheduled tasks, the value will be "cron"

  • TRAVIS_PULL_REQUEST - The PR number, or "false"

  • TRAVIS_SECURE_ENV_VARS - This value is "true" when "secrets" are available to a build

Here is my final GitHub Actions build.yml:

name: Java CI

on:
  push:
  pull_request:
  schedule:
    - cron: '0 0 * * 0' # weekly

jobs:
  build:
    runs-on: ubuntu-latest
    name: Java ${{ matrix.java }}
    strategy:
      matrix:
        java: [8, 11]
    env:
      TRAVIS_REPO_SLUG: ${{ github.repository }} (1)
      TRAVIS_BRANCH: ${{ github.head_ref }} (2)
      TRAVIS_PULL_REQUEST: ${{ github.event.number }} (3)
    steps:
      - uses: actions/checkout@v2

      - name: Set ENV variables
        run: |
          echo "::set-env name=TRAVIS_BRANCH::${TRAVIS_BRANCH:-$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')}" (4)
          echo "::set-env name=TRAVIS_SECURE_ENV_VARS::$(if [ -z "${{ secrets.something }}" ]; then echo "false"; else echo "true"; fi)" (5)
          echo "::set-env name=TRAVIS_EVENT_TYPE::$(if [ "schedule" == "${{ github.event_name }}" ]; then echo "cron"; else echo "${{ github.event_name }}"; fi)" (6)

      - name: Print Travis ENV vars (7)
        run: |
          echo "TRAVIS_BRANCH: ${TRAVIS_BRANCH}"
          echo "TRAVIS_PULL_REQUEST: ${TRAVIS_PULL_REQUEST}"
          echo "TRAVIS_REPO_SLUG: ${TRAVIS_REPO_SLUG}"
          echo "TRAVIS_SECURE_ENV_VARS: ${TRAVIS_SECURE_ENV_VARS}"

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v1
        with:
          java-version: ${{ matrix.java }}

      - name: Build and Test
        run: source ./src/ci/before_install.sh && ./src/ci/build.sh

      - uses: codecov/codecov-action@v1
        with:
         file: target/site/jacoco/jacoco.xml
         fail_ci_if_error: true
1 TRAVIS_REPO_SLUG is the same as github.repository
2 The branch name is tricky. For pull_request jobs it equals github.head_ref. For push jobs it needs to be updated in #4
3 Another easy one, TRAVIS_PULL_REQUEST is github.event.number on pull_request jobs
4 For non-pull-request builds, the TRAVIS_BRANCH env var will be empty. Extract it from GITHUB_REF in the format of refs/heads/<branch-name>
5 There is no generic way to detect if secrets are present so pick a name of a secret you have defined and wrap it in an if/else
6 The push and pull_request event types from Travis CI line up with GitHub Actions, but the "cron" needs to be worked around with another bash if/else
7 Tried and true print line debugging
If you are trying to figure out what properties are available in the build context, you can add a run: echo "${{ toJson(github) }}" line to print them all.

While it’s possible to use the Travis CI environment variables, I don’t recommend it. It’s a great option if you want to test out GitHub Actions or need to run them in parallel in the short term, but to say this option is ugly and difficult to debug, is an understatement. Cleaning up these scripts is out of the scope of this post.

Learn More About CI and Secure Applications

Overall I’m happy with GitHub Actions. I was able to migrate my build with minimal effort. The GitHub Marketplace has a lot of potential. I can use the ability to define actions across multiple repositories, which has me excited. Going forward, I’ll be migrating my other projects to Actions.

If you want to learn more about CI or building secure applications, check out these links:

If you enjoyed this blog post and want to see more like it, follow @oktadev on Twitter, subscribe to our YouTube channel, or follow us on LinkedIn. As always, please leave your questions and comments below—we love to hear from you!