CI with GitHub Actions for Ember Apps: Part 2

ijlee2

Isaac Lee

Posted on August 31, 2020

CI with GitHub Actions for Ember Apps: Part 2

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

we ask GitHub Pages to build the site from gh-pages.

Select the deploy branch in Settings - Options - GitHub Pages.

Select the deploy branch in Options - GitHub 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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Step 3

Finally, create a deploy script in package.json.

{
  "scripts": {
    "deploy": "ember deploy production"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

The public key goes to Settings - Deploy keys.

The public key goes to Deploy keys.

The private key goes to Settings - Secrets.

The private key goes to Secrets.

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' &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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
ijlee2
Isaac Lee

Posted on August 31, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related