Multi-environment Flutter Deployments using Github Pages
Scaling deployments with minimal effort
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:
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.
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.
Let's run the aforementioned workflow by selecting "Run workflow" from the dropdown menu on the right-hand side of the screen
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.
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
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.
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: