CI with GitHub Actions for Ember Apps: Part 2
Isaac Lee
Posted on August 31, 2020
2020 has been a tough, frail year. Last week, I joined many people who were laid off. Still I'm grateful for the good things that came out like Dreamland and CI with GitHub Actions for Ember Apps.
With GitHub Actions, I cut down CI runtimes for work projects to 3-4 minutes (with lower variance and more tests since March). I also noticed more and more Ember projects switching to GitHub Actions so I felt like a pioneer.
Today, I want to patch my original post and cover 3 new topics:
- How to migrate to v2 actions
- How to lower runtime cost
- How to continuously deploy (with ember-cli-deploy)
I will assume that you read Part 1 and are familiar with my workflow therein. Towards the end, you can find new workflow templates for Ember addons and apps.
1. How to Migrate to v2 Actions
In Part 1, you met 3 actions that are officially supported by GitHub:
You can check out the README to find new features and improvements in v2. If you followed my workflow, you should be able to use v2 without a problem.
jobs:
lint:
name: Lint files and dependencies
steps:
- name: Check out a copy of the repo
uses: actions/checkout@v2
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2-beta
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get Yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache Yarn cache and node_modules
id: cache-dependencies
uses: actions/cache@v2
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Install dependencies
run: yarn install --frozen-lockfile
if: steps.cache-dependencies.outputs.cache-hit != 'true'
- name: Lint
run: yarn lint
Notice that actions/cache@v2
allows caching multiple things in one step. As a result, the cache retrieval step (line 29) is simpler.
2. How to Lower Runtime Cost
I neglected to warn cost last time. For private repos, where production apps are likely stored, GitHub Actions charges you by the minute. 2020 taught me that money doesn't grow on trees.
You can control 3 things to lower cost:
- Set operating system
- Lower job runtime
- Lower
timeout-minutes
Even if your repo is public and immune from charge, I recommend the last 2 practices to lower the overall runtime.
a. Set Operating System
In Part 1, I suggested that you can use matrix
to test the app against vari ous operating systems. I must redact because jobs that run on Windows and Mac cost 2 and 10 times as much as those on Linux. The rate difference also applies to storage used by GitHub Actions artifacts, which we will soon leverage.
Unless you have a compelling business requirement, run jobs on Linux only:
jobs:
lint:
name: Lint files and dependencies
runs-on: ubuntu-latest
b. Lower Job Runtime
When a workflow runs, you pay for the sum of all job runtimes. You don't pay for the workflow runtime (except in the sense of feedback loop).
Our workflow has 1 lint and 4 test jobs. Suppose these jobs took 1:40, 3:20, 4:00, 4:30, and 3:40 minutes to run. In total, the jobs took,
1:40 + 3:20 + 4:00 + 4:30 + 3:40 = 17.10 minutes
We round up that number, then multiply by the per-minute rate ($0.008/min for Linux) to arrive at the cost:
18 minutes × $0.008/minute = $0.144
14.4 cents seem trivial until you realize that your team can make hundreds or thousands of commits each month. (See Part 1, Section 1c to learn more about configuring on
correctly.)
There's a silver lining for Ember developers. The predominant jobs in our workflow are tests. A test job takes a while to run because it needs to build the app. What if you can build the test app once and pass it to each job—a form of caching?
Since 2015, ember test
has let you pass --path
to tell there's a pre-built dist
folder somewhere. You can set the location thanks to 2 officially-supported actions:
Even better, the --path
flag works with ember-exam and @percy/ember. Here is a simplified update:
jobs:
build-app:
name: Build app for testing
runs-on: ubuntu-latest
steps:
- name: Build app
run: yarn build:test
- name: Upload app
uses: actions/upload-artifact@v2
with:
name: dist
path: dist
test-app:
name: Test app
needs: [build-app]
runs-on: ubuntu-latest
strategy:
matrix:
partition: [1, 2, 3, 4]
steps:
- name: Download app
uses: actions/download-artifact@v2
with:
name: dist
path: dist
- name: Test
uses: percy/exec-action@v0.3.0
with:
custom-command: yarn test --partition=${{ matrix.partition }} --path=dist
env:
PERCY_PARALLEL_NONCE: ${{ env.PERCY_PARALLEL_NONCE }}
PERCY_PARALLEL_TOTAL: ${{ env.PERCY_PARALLEL_TOTAL }}
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
Notice the use of needs
(line 17) to indicate a dependency among jobs. All test-app
jobs won't start until the build-app
job has finished.
Although the workflow performs 1 additional job, the total runtime can be less because tests can finish sooner. When I introduced this change at work, I saw a 33% decrease (6-8 minutes) in billable minutes. That's 50% more runs for the same cost.
The last thing to note is, we must build the Ember app in the test environment (line 7). The default build
script makes a production build so I wrote build:test
to make a test build. If you pass a production build, the tests won't run and will eventually time out (in CI and locally):
message: >
Error: Browser failed to connect within 120s. testem.js not loaded?
Stderr:
[0824/133551.179006:ERROR:xattr.cc(63)] setxattr org.chromium.crashpad.database.initialized on file /var/folders/2z/93zyyhx13rs879qr8rzyxrb40000gn/T/: Operation not permitted (1)
[0824/133551.180908:ERROR:file_io.cc(89)] ReadExactly: expected 8, observed 0
[0824/133551.182193:ERROR:xattr.cc(63)] setxattr org.chromium.crashpad.database.initialized on file /var/folders/2z/93zyyhx13rs879qr8rzyxrb40000gn/T/: Operation not permitted (1)
DevTools listening on ws://127.0.0.1:63192/devtools/browser/9ffa155c-99b3-4f7f-a53e-b23cff1bf743
[0824/133551.670401:ERROR:command_buffer_proxy_impl.cc(122)] ContextResult::kTransientFailure: Failed to send GpuChannelMsg_CreateCommandBuffer.
c. Lower timeout-minutes
GitHub Actions doesn't emphasize the need to set timeout-minutes
. It's how long a job can run (stall) before GitHub Actions cancels the workflow. You're still charged for the run so it's important to know that the default timeout is 360 minutes (!!).
In short, if a workflow is to fail, let it fail fast. Make sure to set a low timeout-minutes
for each job:
jobs:
build-app:
name: Build app for testing
runs-on: ubuntu-latest
timeout-minutes: 7
lint:
name: Lint files and dependencies
runs-on: ubuntu-latest
timeout-minutes: 7
test-app:
name: Test app
needs: [build-app]
runs-on: ubuntu-latest
timeout-minutes: 7
A good initial value is how long build, lint, and test take locally, plus some wiggle room. Over time, however, you will want to observe runtimes and calibrate timeout.
To help you make a data-driven decision, I created inspect-workflow-runs. The script finds past runs and recommends timeout based on 95% confidence interval:
timeout-minutes ≈ x̅ + 2s
Speaking of failing fast, GitHub Actions lets you cancel in-progress jobs if any matrix
job fails. This may be useful if you use ember-try or cross-resolution testing.
3. How to Continuously Deploy
In Part 1, I mentioned auto-deployment with Heroku. Since then, I got to deploy Ember apps to GitHub Pages and Netlify thanks to open source work. I became curious about deploying apps from a GitHub Actions workflow.
The Ember community has a dedicated addon called ember-cli-deploy. It has several plugins so that you can customize the deployment pipeline. Afterwards, you call ember deploy production
, which you can certainly do from a workflow. The hard parts may be building the pipeline and passing your credentials.
As a concrete example, we'll look at deploying to GitHub Pages with the plugin ember-cli-deploy-git. I'll cover a basic setup and 2 ways to pass credentials. You can review the changes to ember-octane-vs-classic-cheat-sheet to see an implementation.
As for deploying to Netlify, although there is a plugin, I'd use the standalone ember-cli-netlify for simple static sites. Netlify can listen to a push to the default branch (similarly to Heroku) so we just need something to handle routing. You can review the changes to ember-container-query.
a. Setup
Step 1
We'll deploy the app to the gh-pages
branch. After we create the branch,
git checkout --orphan gh-pages
git commit --allow-empty -m 'Created gh-pages branch for deployment'
git push -u origin gh-pages
we ask GitHub Pages to build the site from gh-pages
.
Step 2
Let's come back to the default branch. We need to install a few addons:
ember install ember-cli-deploy ember-cli-deploy-build ember-cli-deploy-git
The command will create config/deploy.js
. For now, we can leave this file alone. We'll look at it later in the context of setting credentials.
Do update config/environment.js
so that GitHub Pages understands the routing of the app:
// config/environment.js
'use strict';
module.exports = function(environment) {
let ENV = { ... };
if (environment === 'production') {
ENV.rootURL = '/your-repo-name';
ENV.locationType = 'hash';
}
return ENV;
};
Step 3
Finally, create a deploy
script in package.json
.
{
"scripts": {
"deploy": "ember deploy production"
}
}
Now, we can run yarn deploy
to deploy the app from the local machine. Let's look at how to deploy from the workflow next.
b. Create a Deploy Key
We can't simply add a step that runs yarn deploy
because GitHub Actions will ask for authentication. When everything is meant to be automated, how do you authenticate?
One solution is to check the public key against a private. We can store the latter as a secret environment variable for the workflow, much like we had with the Percy token. The authentication details are hidden thanks to the ember-cli-deploy-git-ci plugin.
Step 1
Install the plugin and generate a key pair.
ember install ember-cli-deploy-git-ci
ssh-keygen -t rsa -b 4096 -N '' -f deploy_key
The public key (deploy_key.pub
) belongs to Deploy keys in the repo's Settings page. The private key (deploy_key
) goes to Secrets and becomes an environment variable called DEPLOY_KEY
.
After saving these keys in GitHub, please delete deploy_key.pub
and deploy_key
so that they won't be committed to the repo.
Step 2
We update config/deploy.js
to indicate the presence of an SSH key:
// config/deploy.js
'use strict';
module.exports = function(deployTarget) {
let ENV = {
build: {},
git: {
repo: 'git@github.com:your-username/your-repo-name.git',
},
'git-ci': {
enabled: true,
deployKey: process.env.SECRET_KEY,
},
};
...
return ENV;
};
Step 3
Finally, we add a deploy job to the workflow. We can use needs
and if
to describe when the app should be deployed (e.g. when there is a push to the main
branch).
Here's a simplified update:
jobs:
deploy-app:
name: Deploy app
needs: [lint, test-app]
runs-on: ubuntu-latest
timeout-minutes: 7
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Check out a copy of the repo
uses: actions/checkout@v2
- name: Deploy
run: yarn deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}</pre>
c. Reuse Auth Token
Thanks to actions/checkout@v2
, there's an easier way to authenticate—one that doesn't require ember-cli-deploy-git-ci
.
While a job runs, the checkout action persists the auth token in the local git config. As a result, we can set GitHub Actions as the user who wants to deploy the app, but pass our auth token instead:
jobs:
deploy-app:
name: Deploy app
needs: [lint, test-app]
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Check out a copy of the repo
uses: actions/checkout@v2
- name: Set up Git user
run: |
# Set up a Git user for committing
git config --global user.name "GitHub Actions"
git config --global user.email "actions@users.noreply.github.com"
# Copy the Git Auth from the local config
git config --global "http.https://github.com/.extraheader" \
"$(git config --local --get http.https://github.com/.extraheader)"
- name: Deploy
run: yarn deploy
Last but not least, we provide an HTTPS URL in config/deploy.js
.
// config/deploy.js
'use strict';
module.exports = function(deployTarget) {
let ENV = {
build: {},
git: {
repo: 'https://github.com/your-username/your-repo-name.git',
},
};
...
return ENV;
};
4. Conclusion
Thanks to shared solutions in Ember (the Together Framework) and new features in v2 actions, we saw that CI/CD with GitHub Actions continues to work well for Ember apps and addons.
We should watch out for long-running jobs because they cost money (even for public repos in the forms of feedback loop and developer's time). In Part 1, we learned to save time by running tests in parallel and caching node_modules
. In Part 2, by building the test app once and employing a fail-fast strategy.
If you haven't yet, I hope that you will give GitHub Actions a try and share what you learned. I look forward to discover more ways to optimize and enhance workflows.
5. Notes
A few sections in Part 2 were possible thanks to the Ember community:
- Dan Knutsen showed me how to pre-build the app for tests.
- Katie Gengler created the pre-build example in ember.js and directed Dan to it.
-
Jen Weber walked me through how to use
ember-cli-deploy-git
. -
Jan Buschtöns and Dan Freeman found a way to continuously deploy to GitHub Pages without
ember-cli-deploy-git-ci
. They shared their solution on Discord.
Katie kindly informed me that it's also possible to pre-build an addon's demo app for every ember-try
scenario. (I wanted to test an addon at different window sizes.)
ember try:one scenario-name --- ember build --environment=test
Katie recommends caching dist
(with a unique hash based on Node version, scenario name, and lockfile) over uploading it as an artifact. This is to avoid the possibility of passing the wrong dist
to a scenario.
I posted new workflow templates on GitHub Gist.
If you are interested in cross-resolution testing, I recommend studying the workflow for ember-container-query.
Posted on August 31, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.