Multi-environment Flutter Deployments using Github Pages

Multi-environment Flutter Deployments using Github Pages

Scaling deployments with minimal effort

ยท

8 min read

Recently, my team had the pleasure of seeing just how far we can stretch GitHub as an all-inclusive development platform. We've built comprehensive fully-automated release workflows. We've explored how to best distribute packages that are only available privately. And now, we've assembled a simple workflow for how to scale deployments per environment without relying on external services such as Vercel, Netlify, or Firebase Hosting.

Obviously, these tools are all awesome and worth using, but in special cases, the easiest tool to utilize is the one you're already using. This article attempts to explain how we used GitHub to deploy our web app for multiple environments.

What we will build

The end result of this exercise will be a deployment landing page that points to demo applications that mirror our branching structure, where main represents the production release branch and develop represents the staging/integration release branch that developers branch from to implement features. We could have a more complex deployment strategy that includes environment-specific configuration, but for the sake of this article, we will keep it simple.

Getting Started

To follow along, clone the tutorial branch of the starter project. This is your standard, run-of-the-mill counter application so don't bother perusing the code unless this is your first Flutter application. Today, we are traveling on the fantastical voyage that is build automation ๐Ÿฆ„.

On a side note, if you came here with the expectation of writing Dart code, you can make a full stop here. I apologize for waiting this long to break the bad news ๐Ÿ˜Š.

The only interesting file in the starter application is tools/grind.dart. If you are unfamiliar with grinder, it is a task runner not too dissimilar from grunt or gulp. You should install grinder by running {flutter/dart} pub global activate grinder.

Task runners are extremely useful tools for chaining together commands and workflows in an otherwise bash-dominated scripting world. The format for running a task is grind <name_of_task>. Most of the tasks defined here proxy flutter commands, but let's still run thru each task.

build

@DefaultTask('build web app')
@Depends(test)
build() => runAsync('flutter', arguments: ['build', 'web']);

The build task is the default task that runs if no other one is specified. It depends on the test task passing to run and it will build the web version of our app.

test

@Task('run tests')
Future<void> test() => runAsync('flutter', arguments: ['test']);

The test task runs our flutter tests.

get

@Task('get dependencies')
Future<void> get() => runAsync('flutter', arguments: ['pub', 'get']);

The get task installs our project dependencies.

stage

@Task('copy build to destination (default: --dest=public/develop)')
Future<void> stage() async {
  final args = context.invocation.arguments;
  final destination = args.getOption('dest') ?? 'public/develop';
  final baseHref = args.getOption('base-href') ?? 'build/develop';

  final indexPath =
      path.join(Directory.current.path, 'templates', 'index.html');
  final index = await File(indexPath).readAsString();

  final template = Template(index);
  final output = template.renderString({'base_href': baseHref});

  copyDirectory(Directory('build/web'), Directory(destination));
  await File('$destination/index.html').writeAsString(output);
}

The stage task is where all of the ๐Ÿช„ action happens. We accept two arguments, --dest and --base-href that we will use for this task. The --dest argument allows us to define the staging directory of our build output, which in this case will be public/build/{branch_name}. The --base-href argument allows us to set the base URL to use for all relative URLs in the application. This normally is not needed when deploying to a root directory, but because we are deploying multiple environments, each environment will be represented by a folder. Therefore, we will override the <base> defined in the index.html file provided. Using a combination of mustache templates and the --base-href argument will allow us to accomplish this task.

Deployment landing page

Lets add the the deployment landing page. Create a file named public/index.html where public is a folder defined at the project root. This will be the main entry point for the deployment landing page. Users will navigate to environment specific demos of the app from this page. Inside of the file, add the following code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Flutter Examples</title>
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/sakura.css/css/sakura.css"
      type="text/css"
    />
  </head>
  <body>
    <h1>Deployment Landing Page</h1>
    <p>Demo for Flutter</p>
    <h2>Main</h2>
    <p>
      <a href="build/main/">Flutter Demo</a>
    </p>
    <h2>Develop</h2>
    <p>
      <a href="build/develop/">Flutter Demo</a>
    </p>
  </body>
</html>

Ignore everything but the two link tags ( <a href="..."), which will point to the environment-specific builds of our application. If you have a way of serving static files, you can run the command and you should see the following in your browser:

Deployment Landing Page

The links won't take you anywhere yet, so don't worry about them. We can now test our tasks by running grind build and grind stage --dest=public/build/develop --base-href=build/develop from the terminal in the project root. As a result, when we serve the public folder we should be able to navigate to the develop demo.

Develop Demo App

Automation Workflow

Next, we need to set up a workflow to deploy the application. Create a file named .github/workflows/deploy_web.yml where .github is a folder defined at the project root. Inside it, add the following:

name: Deploy web app

on:
  workflow_dispatch:

jobs:
  deploy:
    name: Deploy
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - uses: subosito/flutter-action@v1

      - name: Add pub cache to path
        shell: bash
        run: |
          echo "export PATH=$PATH:$HOME/.pub-cache/bin" >> $GITHUB_PATH

      - name: Install global dependencies
        run: |
          dart pub global activate grinder

      - name: Get local dependencies
        run: flutter pub get

      - name: Build web app
        run: grind build stage --dest=public/build/${GITHUB_REF##*/} --base-href=gh_deployment_example/build/${GITHUB_REF##*/}

      - name: Deploy web app
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public
          keep_files: true

Let's break down each important section.

name: Deploy web app

on:
  workflow_dispatch:

Here we are saying that the workflow should be triggered manually. We are doing this for the sake of testing, but we could easily set up the workflow to run on pushes to the environment branches that we care about like so

name: Deploy web app

on:
  push:
    branches:
      - main
      - develop

This workflow would be triggered automatically by pushes to the main and develop branches.

jobs:
  deploy:
    name: Deploy
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

Next we define a single job deploy to run on the latest macOS version. Our first step in the job will check out the repository.

      - uses: subosito/flutter-action@v1

      - name: Add pub cache to path
        shell: bash
        run: |
          echo "export PATH=$PATH:$HOME/.pub-cache/bin" >> $GITHUB_PATH

      - name: Install global dependencies
        run: |
          dart pub global activate grinder

      - name: Get local dependencies
        run: flutter pub get

The next 4 steps are all project setup steps before we build the application. First, we install Flutter as a global dependency, then we add .pub-cache to the environment path so that we may reference global Dart/Flutter dependencies. The last two steps install global and local project dependencies respectively.

      - name: Build web app
        run: grind build stage --dest=public/build/${GITHUB_REF##*/} --base-href=gh_deployment_example/build/${GITHUB_REF##*/}

This step is where the โœจ magic happens. We will call our build and stage grinder tasks, passing in the --dest and --base-href arguments to the stage command. We use some sorcery to compute the branch name. gh_deployment_example should be swapped out for whatever the repository is named.

      - name: Deploy web app
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public
          keep_files: true

Our final step will deploy the stage directory to our gh-pages branch. Because this build can deploy multiple environments, we specify that we want to keep existing files on that branch so that none of the previously deployed environments are wiped out. We are finally ready to push up our changes and test out the build!

Running the build

From the Actions tab on GitHub, we should see our workflow.

GitHub Action Workflow List

Let's run the aforementioned workflow by selecting "Run workflow" from the dropdown menu on the right-hand side of the screen

Github Workflow Menu

Now you have an important, life-changing decision to make as to how you choose to pass the time while the build runs. You can stare at the build output, get some coffee, or maybe play a video game. Your call. After several minutes, hopefully you will be rewarded with a minty success symbol. Otherwise, I accept cookies and cupcakes as payment for my consulting services ๐Ÿ˜Š.

For kicks, make sure that you run the workflow for both main and develop branches. Also, you'll notice that you can run this workflow for any branch. So in scenarios where you want quick feedback on features, you have the power to build those branches as well!

Enabling GitHub Pages

There is one final step required to view your deployment and that is to enable GitHub pages for the repository. Navigate to the Settings tab on GitHub. Near the end of the page, there is a GitHub pages section. Here we will specify that we want to use the gh-pages branch and the root directory.

GitHub Pages Settings

Once we click save, we should see a message telling us where the page has been deployed. Again you may be met with the difficult decision of how to pass your time before the page is live. Choose wisely.

Deployed! Cue Boyz II Men

Boyz II Men - End Of The Road

We have finally reached the end of the road. When navigating to the link provided, we should see the same landing page from our local testing. When we navigate to either demo we should be met with our beautiful counter app.

Deployed Develop Demo App

Conclusion

To recap, we have demonstrated how to use GitHub actions and a task runner to create a deployment site that also houses all of our application's environment builds. At Superformula, we are using a more complex version of this setup to deploy a Storybook of our Flutter UI components along with Dart Developer documentation (another extremely useful use case).

Multi-environment deployments are an extremely valuable tool that cuts out the middlemen that are native deployments and allows teams to tighten their feedback loops. Hopefully, you have found this tutorial entertaining and helpful. :wave: