What is GitHub Actions?

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform to create automated workflows that can build, test or deploy your code from GitHub. The workflow can be configured to be triggered by an event in the GitHub repository. For example, on push, pull_request, fork, etc. GitHub provides Linux, Windows, and macOS virtual machines to run your workflows, or you can host your own self-hosted runners in your own data centre or cloud infrastructure.

To get started, a workflow file must be defined in the root of your repository in the .github/workflows/ directory. The configuration file uses YAML syntax. So the file must be in *.yml format.

.github/workflows/main.yml

A workflow is a configurable and automated process that will run one or more jobs in sequential or parallel order. It can be triggered by a repository event, manually or at a defined schedule. GitHub Actions will automatically run the workflow based on its configuration. You can define multiple workflow files inside the .github/workflows/ directory which will run in parallel order.

Understanding the Workflow file

The workflow file has several components that define a run or an action. Consider the below example workflow.

.github/workflows/main.yml
name: github-actions-example
on: [push]
jobs:
  check-node-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node -v

Let's break down the example workflow.

  • name: - The name of the workflow. This will be shown in the Actions tab in the repository
  • on: - The event trigger to run the workflow. Here, the workflow will run on a push of the last commit. This can also be a list of events. For example,
on: [push, pull_request]
  • jobs: - A set of steps that execute on the same runner. Each step is either a shell script or an action which is executed in order. Steps can also share data since they are executed on the same runner. A workflow can have multiple jobs and they can take a dependency on another job and will wait for the dependent job to complete before it can run.
  • check-node-version: - Defines the job name.
  • runs-on: The virtual machine that the workflow is run when triggered. GitHub provides Ubuntu, Windows and macOS runners and each workflow run executes in a newly-provisioned virtual machine. The above example is configured to run on the latest version of Ubuntu.
  • steps: - Set of steps to be taken for the job check-node-version Each item is a separate shell script or an action. You can organise steps by adding a name to each step.
steps:
  - name: Checkout
    uses: actions/checkout@v4
  • - uses: actions/checkout@v4 - uses keyword specifies that this step will run the <action-name>@<version> (action/checkout) action checks out (clone) the repository into the runner. This action must be used for the runner to access the repository code.
    - uses: actions/setup-node@v4 - This action sets up Node.js in the runner.
    with: specifies the action attributes as per its documentation.
  • - run: node -v - run keyword executes a shell command in the runner.

Build APK from Workflow

Let's create a workflow to build an APK using a Linux runner. Here we will build a release APK and upload the APK as an artifact so that we can download it and share with anyone.

First, create a new Flutter project and add a new workflow file main.yml in the directory .github/workflows/ in your flutter project repository.

Then we add the following content in the .github/workflows/main.yml file.

.github/workflows/main.yml
name: CI
 
on:
  push:
    branches:
      - main
 
jobs:
  build:
    name: Build APK
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: "temurin"
          java-version: "21"
 
      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.24.4"
 
      - name: Get dependencies
        run: flutter pub get
 
      - name: Build Android APK
        run: flutter build apk --release
 
      - name: Upload Android release artifacts
        uses: actions/upload-artifact@v4
        with:
          name: android_release
          path: "build/app/outputs/apk/release"

The above workflow is defined to trigger on every push to the main branch.

It has only one job build the gets executed in the ubuntu-latest runner with the below steps:

Pretty straightforward right? You can refer to the linked documentation of the actions to configure them.

Once you commit and push the changes to the main branch, GitHub Actions will execute the workflow as shown below.

Workflow action artifact

Workflow action artifact download

Release APK from Workflow

Now that we have a workflow to build an APK and upload the artifacts, let's update the configuration to create a tagged release in the repository. To achieve this, we need to add a new event to trigger on tags, add a new dependent job release, add two new actions to Download Artifact (actions/download-artifact) and Release Action (ncipollo/release-action).

Let's break down the steps

Trigger on Tags

Event to trigger on every push to tags that matches the format (Semantic Versioning format). For example, v0.1.0, v1.0.0, v1.0.0-alpha

on:
  push:
    branches:
      - main
    tags:
      - v[0-9]+.[0-9]+.[0-9]+
      - v[0-9]+.[0-9]+.[0-9]+-alpha*
      - v[0-9]+.[0-9]+.[0-9]+-beta*

Execute the Release Job

Dependent job release gets executed after the job build completes and with the condition having a pushed commit to a tag starting with v*

  • The step version-string sets an environment variable extracted from the GITHUB_REF (pushed tag name).
  • Action actions/download-artifact@v4 downloads all the artifacts to the runner's file system from the current action.
  • Action ncipollo/release-action@v1 creates the release from the downloaded artifacts.
.github/workflows/main.yml
jobs:
  build: ....
 
  release:
    name: Release APK
    runs-on: ubuntu-latest
    needs: build
    if: success() && startsWith(github.ref, 'refs/tags/v')
    steps:
      - name: Generate version string
        id: version-string
        run: echo "VERSION_STR=${GITHUB_REF##*/}" >> $GITHUB_ENV
 
      - name: Download all artifacts
        uses: actions/download-artifact@v4
 
      - name: Create a Release APK
        uses: ncipollo/release-action@v1
        with:
          allowUpdates: true
          artifacts: "android_release/*.apk"
          name: Release ${{ env.VERSION_STR }}
          prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') }}
          tag: ${{ env.VERSION_STR }}
          token: ${{ secrets.GITHUB_TOKEN }}

Using Environment Variables

You can use environment variables in your workflow file to store information. Here I'm using GitHub's default environment file to store variables that I'll be using with workflow commands.

Here's how we can use it in our workflow:

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: Set build mode
        id: build-mode
        run: |
          BUILD_MODE='release'
          if [[ "${GITHUB_REF}" =~ ^refs\/tags\/v* ]]; then
              BUILD_MODE='release'
          elif [[ "${GITHUB_REF}" =~ ^refs\/heads\/main ]]; then
              BUILD_MODE='release'
          fi
          echo "Build mode: ${BUILD_MODE}"
          echo "BUILD_MODE=${BUILD_MODE}" >> $GITHUB_ENV
 
      - name: Generate version string
        if: startsWith(github.ref, 'refs/tags/v')
        id: version-string
        run: echo "VERSION_STR=${GITHUB_REF##*/}" >> $GITHUB_ENV
...

Here, we add the BUILD_MODE and VERSION_STR as variables to the environment file.

Note: To avoid issues, it's good practice to treat environment variables as case-sensitive, irrespective of the behavior of the operating system and shell you are using.

Complete Workflow - Build and Release APK

The final configuration of the workflow file looks like this. It will be triggered by the last pushed commit on the main branch or any matching tags of the repository. Check it out here.

.github/workflows/main.yml
name: CI
 
on:
  push:
    branches:
      - main
    tags:
      - v[0-9]+.[0-9]+.[0-9]+
      - v[0-9]+.[0-9]+.[0-9]+-alpha*
      - v[0-9]+.[0-9]+.[0-9]+-beta*
 
jobs:
  build:
    name: Build APK
    runs-on: ubuntu-latest
    steps:
      - name: Set build mode
        id: build-mode
        run: |
          BUILD_MODE='release'
          if [[ "${GITHUB_REF}" =~ ^refs\/tags\/v* ]]; then
              BUILD_MODE='release'
          elif [[ "${GITHUB_REF}" =~ ^refs\/heads\/main ]]; then
              BUILD_MODE='release'
          fi
          echo "Build mode: ${BUILD_MODE}"
          echo "BUILD_MODE=${BUILD_MODE}" >> $GITHUB_ENV
 
      - name: Generate version string
        if: startsWith(github.ref, 'refs/tags/v')
        id: version-string
        run: echo "VERSION_STR=${GITHUB_REF##*/}" >> $GITHUB_ENV
 
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: "temurin"
          java-version: "21"
 
      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: "3.24.4"
 
      - name: Get dependencies
        run: flutter pub get
 
      - name: Build Android APK
        run: flutter build apk --$BUILD_MODE
 
      - name: Build Android App Bundle
        if: startsWith(github.ref, 'refs/tags/v')
        run: flutter build appbundle --$BUILD_MODE
 
      - name: Rename APK with version string
        if: startsWith(github.ref, 'refs/tags/v')
        run: mv build/app/outputs/apk/$BUILD_MODE/app-$BUILD_MODE.apk build/app/outputs/apk/$BUILD_MODE/app-$BUILD_MODE-$VERSION_STR.apk
 
      - name: Rename App Bundle with version string
        if: startsWith(github.ref, 'refs/tags/v')
        run: mv build/app/outputs/bundle/$BUILD_MODE/app-$BUILD_MODE.aab build/app/outputs/bundle/$BUILD_MODE/app-$BUILD_MODE-$VERSION_STR.aab
 
      - name: Upload Android release artifacts
        if: ${{ env.BUILD_MODE == 'release' }}
        uses: actions/upload-artifact@v4
        with:
          name: android_release
          path: "build/app/outputs/apk/release"
 
      - name: Upload Android App Bundle artifacts
        if: startsWith(github.ref, 'refs/tags/v')
        uses: actions/upload-artifact@v4
        with:
          name: android_appbundle
          path: "build/app/outputs/bundle/release"
 
      - name: Upload Android debug artifacts
        if: ${{ env.BUILD_MODE == 'debug' }}
        uses: actions/upload-artifact@v4
        with:
          name: android_debug
          path: "build/app/outputs/apk/debug"
 
  release:
    name: Release APK
    runs-on: ubuntu-latest
    needs: build
    if: success() && startsWith(github.ref, 'refs/tags/v')
    steps:
      - name: Generate version string
        id: version-string
        run: echo "VERSION_STR=${GITHUB_REF##*/}" >> $GITHUB_ENV
 
      - name: Download all artifacts
        uses: actions/download-artifact@v4
 
      - name: Create a Release APK and AAB
        uses: ncipollo/release-action@v1
        with:
          allowUpdates: true
          artifacts: "android_release/*.apk,android_appbundle/*.aab"
          name: Release ${{ env.VERSION_STR }}
          prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') }}
          tag: ${{ env.VERSION_STR }}
          token: ${{ secrets.GITHUB_TOKEN }}

Artifacts expire in 90 days and consume Storage for Actions and Packages, so you can tag and release your code following the semantic versioning scheme.

That's it! You can build and release your APK right from GitHub easily. GitHub Actions provide plenty more features for CI/CD automation. Check out their official documentation for more tutorials and guides.

GitHub repo: https://github.com/pasanjg/flutter-gh-actions