With GitHub Actions you don't have to do boring tasks manually ever again

Nobody likes repetitive tasks. So why should you run all your tests manually with every new code or—even worse—check that the new code didn’t break your build process? Yeah, I know you want to spend your precious time on shiny features without having to waste hours tweaking your build pipeline. Or maybe you have excuses like “I would have to pay for the automation” or “It’s time-consuming to prepare pipelines, and I don’t have time for it.” Let me show you it can be done for free and very (and I mean very, VERY) fast.

At first sight, it might seem the automation of the building process is important only for big enterprise codebases. I reckon nothing could be further from the truth. I believe automated tasks might be beneficial also for small open-source or even personal side projects. But first things first ... 

TL;DR If you are familiar with GitHub Actions, you can skip the theory and jump to the specific stack in the Examples section below.

Continuous Integration and Continuous Delivery

Let’s start with the basics. The terms might sound very technical at first. However, the basic concept is quite simple. Continuous integration is the automation of building and testing. This means that typically, with some code changes, the machine checks whether it’s possible to build the project and whether your tests are passing. Continuous delivery is a little bit broader. To sum it up, this automation process will deploy or publish your code to various environments. This might be a little bit abstract and connected with the nature of your project, but you can visualize it as publishing to package registries like NPM or Nuget—or deploying your site to a staging or production environment.

GitHub Actions

GitHub Actions are the feature of GitHub that consists of the API and an environment for running your tasks. You just need to create a YAML file at a specific location in the repository. The configuration .yml file contains all the specific information about the environment, such as trigger eventsjobs, or strategies. Additionally, you can choose the environment where you want to run your tasks—it might be Linux (Ubuntu), Windows (Windows Server), or even macOS (Big Sur or Catalina).

The anatomy of the Action

Each action is represented by a .yml file located directly in the repository at the .github/workflows location. To be able to run it, you need to allow the Actions feature in the repository’s Settings.

Note: If you are not familiar with the YAML format, for the purposes of the GitHub Actions, you just need to know that key-value pairs are represented by key: value. The quotes for string values are optional. Nesting of objects is done by indentation (typically two spaces), and items of the array are denoted by a dash.

key: value
  nested_property: Nested value
  my_array:
    - First Item
    - Second Item

GitHub Actions basic syntax keys

  • name - The name of the workflow.
  • on - Is required, specifies events when you want to run your action.
  • jobs - A job is a unit of work; jobs run in parallel by default. Each job runs in a runner environment specified by runs-on.
  • jobs.<job_id>.runs-on - Is required, specifies the type of the machine to run the job.
  • jobs.<job_id>.steps - A step is an individual task that can run commands in a job. A step can be either an action or a shell command. Each step in a job executes on the same runner, allowing the actions in that job to share data with each other (definition by GitHub).
  • jobs.<job_id>.steps[*].uses - Selects an action to run as part of a step in your job. This is beneficial for reusing existing actions. You can check existing actions in the official actions repository or on the marketplace.
  • jobs.<job_id>.runs-on - Provides a shell where you can run your commands.

Hello World Action

Let’s write the action that runs the Python script every day. This action will run on Ubuntu, using code from our repository, and will run our main.py script on the environment with Python 3.8. The explanation of each line is described in the respective comment directly in the code.

  1. Create main.py in the root of your repository with content print("Hello, World!").
  2. In the .github/workflows create the minimal-action.yml file with the following content.
name: Hello World From Python   #  Represents the name of the whole action.
on:   # Specifies the section where we describe our build triggers.
  schedule:   # Specifies the section where we describe the schedule of running the action.
    - cron:  '* 1 * * *'   # The CRON expression describing when to run the action.
  workflow_dispatch:   #  Adds the ability to run the action manually by button on the Actions tab.
jobs:   # Specifies the section where we describe our jobs.
  hello_world_job:   # Specific job section.
    name: A greeting job   # The name of the job.
    runs-on: ubuntu-latest   # Describes the environment.
    steps:   # Specifies the section where we describe the job's steps.
    - name: Checkout   # The name of the step.
      uses: actions/checkout@v2   # Using already existing action actions/checkout@v2. This action provides us with access to the code of the repository.
    - name: Set up Python 3.8   # The name of the step.
      uses: actions/setup-python@v1   # Using already existing action actions/setup-python@v1. This action sets up a Python environment for us.
      with:   # Configuration of the python action.
        python-version: 3.8   # Specific python version.
    - name: Run the script   # The name of the step.
      run: python main.py   # Command for running our main.py.

Real-world examples

It’s not possible to cover all capabilities and combinations here. Nevertheless, in this section, you can find some real-world examples—you can choose them for scaffolding your Action quickly for your specific stack and use case. They showcase configuration for various languages, platforms, and publishing to external package repositories. However, before we start, we’ll need some more commands for these real-world examples.

Build Node.js app and publish to the NPM registry

The Action that builds the Node.js project written in TypeScript and publishes the package to the NPM registry when the release is created. The publishing of the NPM package is performed by the npm publish command.

on:
  release:
    types: [published]
name: publish-to-npm
jobs:
  publish:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
          registry-url: 'https://registry.npmjs.org'
      - run: npm install
      - run: npm run build
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_API_KEY }}

Build Ruby website and publish it to GitHub Pages

The action that runs with push or pulls request to master. The action builds the Jekyll project and publishes it to a specific folder. After running this action, the site is accessible on GitHub Pages.

name: Ruby
on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        ruby-version: ["2.6"]
    env:
      PROJECT_ID: ${{ secrets.PROJECT_ID }}
    steps:
      - uses: actions/checkout@v2
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.ruby-version }}
          bundler-cache: true # runs 'bundle install' and caches installed gems automatically
      - name: Update gems
        run: gem update --system
      - name: Install required packages
        run: gem install jekyll bundler kontent-jekyll
      - name: Build the output
        run: bundle exec jekyll build
        env: 
          JEKYLL_ENV: production
      - name: Deploy 🚀
        if: github.ref == 'refs/heads/master'   # Checks if the current branch is master.
        uses: JamesIves/github-pages-deploy-action@3.6.2
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: gh-pages
          FOLDER: _site
          CLEAN: true # Automatically remove deleted files from the deploy branch

Build the Ruby project and publish it to RubyGems

The Ruby project is built and published to RubyGems. This example uses dawidd6/action-publish-gem action for publishing.

name: publish-gem
on:
  release:
    types: [published]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 2.6
    - name: Install dependencies
      run: bundle install
    - name: Run tests
      run: bundle exec rake
    - name: Publish gem
      uses: dawidd6/action-publish-gem@v1
      with:
        api_key: ${{secrets.RUBYGEMS_API_KEY}}

Node.js app with tests

The tests are performed by executing the npm run test:all command.

name: Test
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14]
    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm i
    - run: npm run test:all

Node.js app with UI tests using a browser

Sometimes the test environment requires a browser, and default virtual environments come with a lot of preinstalled browsers. This action runs tests directly in the browser using the npm run test:all command.

name: Test
on: [pull_request]
jobs:
  build:
    runs-on: windows-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm i
    - run: npm run test:all

PHP project using Composer and Codecov

This action sets up the PHP environment on the Ubuntu machine using shivammathur/setup-php@v2. Moreover, it validates and caches Composer packages. In the end, it runs a code coverage tool by Codecov.

name: Build & Test & Report
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:        
        php-versions: ['7.0', '7.1', '7.2', '7.3']
        phpunit-versions: ['latest']
    steps:
    - uses: actions/checkout@v2
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php-versions }}
        extensions: mbstring, intl
        ini-values: post_max_size=256M, max_execution_time=180
        coverage: xdebug        
        tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }}
    - name: Validate composer.json and composer.lock
      run: composer validate --strict
    - name: Cache Composer packages
      id: composer-cache
      uses: actions/cache@v2
      with:
        path: vendor
        key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
        restore-keys: |
          ${{ runner.os }}-php-
    - name: Install dependencies
      run: composer install --prefer-dist --no-progress --no-suggest
    - name: Coverage
      run: vendor/bin/phpunit --coverage-clover coverage.xml
    - name: Codecov
      uses: codecov/codecov-action@v1  

Build, test, and publish to Nuget .NET Core package

This example covers the release process of the .NET Core SDK package. The action restores packages, builds the solution, runs tests, and publishes artifacts to Nuget.

name: Publish to NuGet
on:
  release:
    types: [published]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 5.0.x
    - name: Extract version from tag
      id: get_version
      uses: battila7/get-version-action@v2
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore --configuration Release /p:ContinuousIntegrationBuild=true /p:Version="${{ steps.get_version.outputs.version-without-v }}"
    - name: Test
      run: dotnet test --no-build --verbosity normal --configuration Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
    - name: Codecov
      uses: codecov/codecov-action@v1
    - name: Pack
      run: dotnet pack --no-build --include-symbols --verbosity normal --configuration Release --output ./artifacts /p:PackageVersion="${{ steps.get_version.outputs.version-without-v }}"
    - name: Upload artifacts
      uses: actions/upload-artifact@v2
      with:
        name: "NuGet packages"
        path: ./artifacts
    - name: Publish artifacts to NuGet.org
      run: dotnet nuget push './artifacts/*.nupkg' -s https://api.nuget.org/v3/index.json -k ${NUGET_API_KEY} --skip-duplicate
      env:
        NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
    - name: Upload artifacts to the GitHub release
      uses: Roang-zero1/github-upload-release-artifacts-action@v2.1.0
      with:
        args: ./artifacts
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Build, test, and publish Java project to Nexus registry

In this Action, the package is built and published using Gradle. The package is deployed to the Nexus repository.

name: Publish package to the Maven Central Repository
on:
  release:
    types: [created]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Set up Java
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - name: Grant execute permission for gradlew
        run: chmod +x gradlew
      - name: Version release
        run: echo Releasing verion ${{ github.event.release.tag_name }}
      - name: Publish package
        run: ./gradlew publish
        env:
          RELEASE_TAG: ${{ github.event.release.tag_name }}
          NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
          NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
          SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
          SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
          SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}

Build test and publish to Cocoapods project written in Swift

At first, the Cocoapods dependencies are restored. Afterward, the project is built, and the Action runs tests. In the end, the package is pushed to the Cocoapods repository using the pod trunk push command.

on:
  release:
    types: [published]
name: publish-to-cocoapods
jobs:
  publish-to-cocoapods:
    name: publish-to-cocoapods
    runs-on: macOS-latest
    strategy:
        matrix:
          destination: ['platform=iOS Simulator,OS=12.2,name=iPhone 11']
    steps:
      - name: Checkout
        uses: actions/checkout@master
      - name: Build and test
        run: |
          gem install cocoapods
          pod repo update
          pod install --project-directory=Example
          set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/KenticoKontentDelivery.xcworkspace -scheme KenticoKontentDelivery-Example -sdk iphonesimulator -destination 'name=iPhone 11' ONLY_ACTIVE_ARCH=NO | xcpretty
      - name: Publish Cocoapod
        run: pod trunk push
        env:
          COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }}

Bonus: GitHub Actions not only for CI/CD automation

Not only can GitHub Actions be used for automating your build, running tests, and release pipelines but you can also leverage them for scheduling automatic tasks, such as web scraping or automatic update routines. This last bonus action runs the Python script at a specific time. The Python script does some automation—in this case, it scrapes the HTML page and makes data readable for machine processing. The output of the script is then committed and pushed to the specified GIT repository.

name: Python application
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
  schedule:
    - cron:  '0 0 * * *'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python 3.8
      uses: actions/setup-python@v1
      with:
        python-version: 3.8
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    - name: Lint with flake8
      run: |
        pip install flake8
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Run script
      run: |
        python stocks.py
    - name: Deploy page
      run: |
        git config --local user.email "action@github.com"
        git config --local user.name "GitHub Action"
        git add docs/index.html -f
        git commit -m "Update docs/index.html" -a
        git push "https://makma:${{ secrets.GITHUB_PERSONAL_TOKEN }}@github.com/makma/stocks-movement.git" HEAD:master --follow-tags

Debrief

I believe GitHub Actions can be utilized for many different scenarios. You can find the majority of the example actions above in real-world use—they were based on and taken over from open-source Kentico Kontent’s GitHub. Feel free to take a look, get inspired, or maybe improve them ;). Didn’t you find your use case in the examples above, or did I not include your favorite GitHub Actions feature? Let me know on Twitter or Discord. I’ll be happy to discuss your use case and update the article.

Originally published at hackernoon.

Published April 21, 2021

Personal blog
Martin Makarsky on Twitter