CI with GitHub Actions for Ember Apps: Part 2
Tags: ember, javascript, ci, tutorial
Published: 2020-08-31 16:05:49

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](https://www.youtube.com/watch?v=BE7vBk_zLA4&amp;list=PL8tkzXKlhGxn8vedFOKUngfcdnz6MV40M) and [CI with GitHub Actions for Ember Apps](https://crunchingnumbers.live/2020/03/17/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](http://ember-cli-deploy.com/))

I will assume that you read [Part 1](https://crunchingnumbers.live/2020/03/17/ci-with-github-actions-for-ember-apps/) 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:

- [actions/checkout](https://github.com/actions/checkout)
- [actions/setup-node](https://github.com/actions/setup-node)
- [actions/cache](https://github.com/actions/cache)

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.

```yaml
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](https://docs.github.com/en/github/setting-up-and-managing-billing-and-payments-on-github/about-billing-for-github-actions#about-billing-for-github-actions) 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**:

```yaml
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,

```text
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:

```text
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](https://crunchingnumbers.live/2020/03/17/ci-with-github-actions-for-ember-apps/) 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](https://github.com/ember-cli/ember-cli/pull/4874), `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:

- [actions/upload-artifact](https://github.com/actions/upload-artifact)
- [actions/download-artifact](https://github.com/actions/download-artifact)

Even better, the `--path` flag works with [ember-exam](https://ember-cli.com/ember-exam/docs/randomization-iterator) and [@percy/ember](https://docs.percy.io/docs/ember). Here is a simplified update:

```yaml
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):

```bash
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](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes) (!!).

In short, if a workflow is to fail, let it **fail fast**. Make sure to set a low `timeout-minutes` for each job:

```yaml
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](https://github.com/ijlee2/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](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast) if any `matrix` job fails. This may be useful if you use [ember-try](https://github.com/ember-cli/ember-try) or [cross-resolution testing](https://crunchingnumbers.live/2020/06/07/container-queries-cross-resolution-testing/).


## 3. How to Continuously Deploy

In Part 1, I mentioned auto-deployment with [Heroku](https://www.heroku.com/). Since then, I got to deploy Ember apps to [GitHub Pages](https://pages.github.com/) and [Netlify](https://www.netlify.com/) 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](http://ember-cli-deploy.com/). 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](https://github.com/ef4/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](https://github.com/ember-learn/ember-octane-vs-classic-cheat-sheet/pull/58) to see an implementation.

As for deploying to Netlify, although there is a [plugin](https://github.com/exelord/ember-cli-deploy-netlify-cli), I'd use the standalone [ember-cli-netlify](https://github.com/shipshapecode/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](https://github.com/ijlee2/ember-container-query/pull/54/files).


### a. Setup

#### Step 1

We'll deploy the app to the `gh-pages` branch. After we create the branch,

```bash
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`.

<figure>
<img src="https://crunchingnumbersdotlive.files.wordpress.com/2020/08/section_3a_github_pages.png" alt="Select the deploy branch in Settings - Options - GitHub Pages." width="600" height="300" />

<figcaption>Select the deploy branch in Options - GitHub Pages.</figcaption>
</figure>


#### Step 2

Let's come back to the default branch. We need to install a few addons:

```bash
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:

```javascript
// 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`.

```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](https://github.com/dfreeman/ember-cli-deploy-git-ci) plugin.


#### Step 1

Install the plugin and generate a key pair.

```bash
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`.

<figure>
<img src="https://crunchingnumbersdotlive.files.wordpress.com/2020/08/section_3b_public_key.png" alt="The public key goes to Settings - Deploy keys." width="600" height="300" />

<figcaption>The public key goes to Deploy keys.</figcaption>
</figure>

<figure>
<img src="https://crunchingnumbersdotlive.files.wordpress.com/2020/08/section_3b_private_key.png" alt="The private key goes to Settings - Secrets." width="600" height="300" />

<figcaption>The private key goes to Secrets.</figcaption>
</figure>

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:

```javascript
// 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:

```yaml
jobs:
  deploy-app:
    name: Deploy app
    needs: [lint, test-app]
    runs-on: ubuntu-latest
    timeout-minutes: 7
    if: github.event_name == 'push' &amp;&amp; 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:

```yaml
jobs:
  deploy-app:
    name: Deploy app
    needs: [lint, test-app]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: github.event_name == 'push' &amp;&amp; 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`.

```javascript
// 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](https://github.com/dknutsen) showed me how to pre-build the app for tests.
- [Katie Gengler](https://github.com/kategengler) created the pre-build example in [ember.js](https://github.com/emberjs/ember.js/blob/master/.github/workflows/ci.yml#L36-L63) and directed Dan to it.
- [Jen Weber](https://github.com/jenweber) walked me through how to use `ember-cli-deploy-git`.
- [Jan Buschtöns](https://github.com/buschtoens) and [Dan Freeman](https://github.com/dfreeman) found a way to continuously deploy to GitHub Pages without `ember-cli-deploy-git-ci`. They shared their solution on [Discord](https://discordapp.com/channels/480462759797063690/485881813370273792/745992323485401150).

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.)

```bash
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.

- Ember addons: [yarn](https://gist.github.com/ijlee2/25004d700cd008ee08ecc4873c109a5b), [npm](https://gist.github.com/ijlee2/fba20304670b8d2b71f0b81cd07ea026)
- Ember apps: [yarn](https://gist.github.com/ijlee2/1c864ebe96a55f239e80800829ef0bf4), [npm](https://gist.github.com/ijlee2/6576ae82895d522ccc0695518c2a6ce7)

If you are interested in cross-resolution testing, I recommend studying the [workflow for ember-container-query](https://github.com/ijlee2/ember-container-query/blob/main/.github/workflows/ci.yml).