Please note: this post was written for GitHub Actions v1. GitHub Actions v2 has been released which deprecates the HCL syntax for workflows which renders some parts of this blog post unusable. To learn more about GitHub Actions v2, please refer to the documentation and the excellent posts by Edward Thomson.
In recent weeks I've helped develop a website for a very exciting project at Awkward called Coffee by Benjamin. Coffee by Benjamin is a coffee roasting kit that allows anyone to roast their coffee at home, this guarantees the freshness of the coffee. The project will launch on Kickstarter soon. If you'd like to keep notified about this project you can follow them on Instagram or visit the website.
This project is my last one at Awkward as I'll be taking up a new challenge at another company soon. Even though I won't be a part of the project going forward, I still want to share something about the way we've been developing and shipping the website by utilizing React, feature flags, Netlify, and GitHub Actions.
Problem statement
The website will launch in three separate phases outlined below. We're currently in phase 1 but we're nearing completion on phase 2. Meanwhile, we've already started development on phase 3.
- Phase 1: a simple landing page where people can fill in their email address to get notified when the project launches.
- Phase 2: a full-fletched website which contains more information about the project, a FAQ and a support form. This will launch together with the launch of the Kickstarter campaign.
- Phase 3: integrate Shopify into the website to sell the product directly. This will launch after the project has been successfully funded and shipped.
Even though phase 3 won't launch till much later, we wanted to start development on this phase as soon as possible because it's the most complicated part of the website to build. This allows us to start testing the shop functionality long before it's launched and catch costly bugs from creeping into the website.
Now we could build phase 3 in a separate branch, but we'd have to constantly update and solve merge conflicts on this branch when we update the phase 2 website. This is especially hard because there are a lot of overlapping parts that we'll change in phase 3. Furthermore, this would result in having to merge a gigantic pull request when phase 3 launches which comes with the risk of bugs in existing functionality. Instead, we want to gradually merge functionality from phase 3 in the main branch without exposing it to the public. We also want the team to be able to check the progress on both phase 2 and phase 3. Finally, we'd like to completely exclude any code from phase 3 while phase 2 is live so that we don't ship any unnecessary code.
In the rest of the post I'll explain how we used a combination of feature flags, Netlify and GitHub Actions to achieve these goals.
Feature flags
The problem statement just screams for feature flags, which is exactly what we'll be using. Feature flags allow us to ship parts of phase 3 but don't actually show them to the public. Let's take a look at a definition of feature flags:
Release Toggles allow incomplete and un-tested codepaths to be shipped to production as latent code which may never be turned on.
The nice thing about feature flags is that it allows you to switch between new and old functionality with the flip of a switch. Usually you do this by wrapping new functionality in a condition like so:
function Header() {
if (USE_NEW_FEATURE) {
return <NewHeader />;
}
// feature flag is not enabled
return <OldHeader />;
}
In code that's affected by a feature flag, you'll add new code without replacing the old code. This allows pull requests with new but overlapping functionality to be merged as they won't replace any existing functionality. Later when the feature flag is being phased out you can remove the conditions and remove any old code.
Let's see how we can implement this into our stack.
Feature Flags in Create React App
We can implement feature flags by using environment variables which Create React App supports out of the box. The benefits of using environment variables are that they're easy to use and they're compile-time constants, which means that code that is guarded by a condition that checks for the flag being enabled will be completely excluded from a build where the flag was disabled.
Environment variables in Create React App can be supplied in a .env
file. The .env
file will contain the default value to use and is checked into Git and will only be changed when phase 3 goes live.
.env
:
REACT_APP_SHOPIFY_INTEGRATION_ENABLED=false
Now we can use the feature flag in App.js
to conditionally render the shop routes. By conditionally rendering the shop routes using a compile-time constant, the code won't end up in the production bundle unless the flag is enabled and users won't be able to route to these pages. The code for the pages will still end up in the production bundle, more on that later.
src/App.js
:
import React, { Suspense } from 'react';
// ... more imports hidden
import Home from 'pages/Home';
import Shop from 'pages/shop';
import Cart from 'pages/cart';
import ProductDetail from 'pages/product-detail';
const App = () => (
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/faq" component={Faq} />
<Route path="/support" component={Support} />
{process.env.REACT_APP_SHOPIFY_INTEGRATION_ENABLED === 'true' && (
<>
<Route path="/shop" component={Shop} />
<Route path="/cart" component={Cart} />
<Route path="/product/:productId" component={ProductDetail} />
</>
)}
</Switch>
</Router>
);
ReactDOM.render(<App />, document.getElementById('root'));
Now that we've got the feature flag set up developers can add a .env.local
(or any of the other supported .env
files) which won't be checked into git.
.env.local
:
REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true
Configuring Netlify
Now only developers can see the Shopify integration by checking out locally and changing the environment variable in .env.local
, what about other people that might want to review the site with a simple link? This is where Netlify comes in. Netlify allows developers to configure the build settings per branch and all branches will be deployed with a unique URL (separately from deploy previews), I'll let the Netlify documentation speak for itself:
Branch deploys are published to a URL which includes the branch name as a prefix. For example, if a branch is called staging, it will deploy to staging--yoursitename.netlify.com.
NOTE: You may have to manually set the branch deploys setting to deploy all branches, this is explained in the Netlify documentation.
We can add a branch in Git called shop-staging
and configure netlify.toml
to build this branch with the REACT_APP_SHOPIFY_INTEGRATION_ENABLED
feature flag enabled.
netlify.toml
:
[build]
publish = "build"
command = "npm run build"
[context."shop-staging"]
command = "REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true npm run build"
Prefixing the build command with REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true
will override the settings in .env
. The site with the feature flag enabled will now be automatically deployed to shop-staging--yoursitename.netlify.com. We can now give this URL to testers and they'll be able to check out the progress on phase 3 and they can still check out the progress on phase 2 by visiting develop--yoursitename.netlify.com. You can also use this approach to enable the feature flag for deploy previews for certain pull requests.
There's still one problem though, the shop-staging
branch will have to be to kept in sync with the main branch (in our case develop
). Luckily, GitHub provides an extensive API which provides a way to do a fast-forward update for a branch, this allows us to keep the shop-staging
branch in sync with the develop
branch. All we have to do is provide it the ref we want to update (heads/shop-staging
) and a commit SHA of the latest commit on the develop branch and then shop-staging
will be in sync with the develop
branch. Furthermore, we can automate this process by using GitHub Actions!
Creating a GitHub Action to keep branches in sync
GitHub actions, just like shell commands, are extremely composable. There's a lot you can accomplish by composing a few predefined actions. In this case we technically only need the Filter action and the cURL
action. But I couldn't get the cURL
action to accept a JSON body with an interpolated value, so we'll be creating our own.
There are two ways to create GitHub Actions, you can create a separate repository that contains the Action, this way other projects will be able to reuse the Action. But for something small that you won't reuse you can create an Action right inside of the repository where the rest of the code for your project lives.
We first create a folder .github
, inside of it we create a folder called branch-sync-action
. We must then create a Dockerfile
, the contents are copied from the cURL
action, we just change some of the labels. This Dockerfile
ensures that we can use cURL
which we'll use to do the HTTP call.
.github/branch-sync-action/Dockerfile
FROM debian:stable-slim
LABEL "com.github.actions.name"="Branch Sync"
LABEL "com.github.actions.description"=""
LABEL "com.github.actions.icon"="refresh-cw"
LABEL "com.github.actions.color"="white"
COPY entrypoint.sh /entrypoint.sh
RUN apt-get update && \
apt-get install curl -y && \
apt-get clean -y
ENTRYPOINT ["/entrypoint.sh"]
Next, we create an entrypoint.sh
which is the script that will be executed when running the action.
.github/branch-sync-action/entrypoint.sh
#!/bin/sh
TARGET_BRANCH=$1
curl \
-X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
-d "{\"sha\": \"$GITHUB_SHA\"}" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/git/refs/heads/$TARGET_BRANCH"
$1
stands for the first argument provided to the script. For clarity we give it the name TARGET_BRANCH
.
Don't forget to provide execution permissions by doing chmod +x entrypoint.sh
.
That's it for the action itself. Now we have to hook it up in a workflow:
.github/main.workflow
workflow "Sync shop-staging branch with develop" {
on = "push"
resolves = ["Sync Branch"]
}
action "Filter develop branch" {
uses = "actions/bin/filter@master"
args = "branch develop"
}
action "Sync Branch" {
needs = ["Filter develop branch"]
uses = "./.github/sync-branch-action"
secrets = ["GITHUB_TOKEN"]
args = ["shop-staging"]
}
In .github/main.workflow
we define workflows for our project. Workflows decide which actions to run and when. In the workflow
block we tell it when to run by defining the on
attribute, in our case the workflow should run for every push
event, we also define the actions it should execute (in parallel) by defining the resolves
attribute.
Next, we define the filter action. GitHub will send a push
event for every push to any branch, we want to add a filter so that we only sync the shop-staging
branch when someone pushes to the develop
branch, we're not interested in pushes to any other branch. In the uses
parameter we point to the slug of the GitHub repository that provides this action and in this case the folder within this repository (filter). The @master
part tells it to use the code that was published on the master branch.
Finally we add the action that syncs the shop-staging
branch with the develop
branch. It has the needs
parameter defined which tells GitHub Actions that it should first run the filter action and only continue with Sync Branch
if the filter action succeeds. Furthermore we define the uses
parameter which will point to the folder containing the Dockerfile
and entrypoint.sh
which is used by GitHub Actions to run it. We also pass it the GITHUB_TOKEN
as a secret which we need to make an authenticated HTTP call, GITHUB_TOKEN
is a uniquely generated token for every project on GitHub. Lastly, we provide the arguments for entrypoint.sh
which is the target branch it should sync to.
We'll end up with a flow looking like this:
It's important to note that the sync is one-way only. Everything that's pushed to develop
will be fast-forwarded to shop-staging
, if you're pushing to shop-staging
nothing will happen, it will cause problems with future synchronization because updates can't be fast-forwarded anymore. You can solve this by enabling the force
parameter in the cURL
request or by resetting the shop-staging
branch using git reset
.
Lazy loading shop routes
A last problem we still have to tackle is excluding phase 3 related code from the bundle while phase 2 is live. We can tackle this by utilizing some new features released in React just last year: React.lazy
and Suspense
. The changes we have to make to our code are quite minimal, we have to change the way we're importing the shop pages by utilizing React.lazy
and dynamic imports:
src/App.js
:
import React, { Suspense } from 'react';
// ... more imports hidden
import Home from 'pages/Home';
const Shop = React.lazy(() => import('pages/shop'));
const Cart = React.lazy(() => import('pages/cart'));
const ProductDetail = React.lazy(() => import('pages/product-detail'));
const App = () => (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/faq" component={Faq} />
<Route path="/support" component={Support} />
{process.env.REACT_APP_SHOPIFY_INTEGRATION_ENABLED === 'true' && (
<>
<Route path="/shop" component={Shop} />
<Route path="/cart" component={Cart} />
<Route path="/product/:productId" component={ProductDetail} />
</>
)}
</Switch>
</Router>
</Suspense>
);
ReactDOM.render(<App />, document.getElementById('root'));
Now the shop pages will not end up in the main bundle, they will instead be lazily loaded when a user hits one of the shop routes. Which is impossible when the flag is not enabled. All of the routes are wrapped in a Suspense
component which is responsible for showing a fallback state when visiting one of the lazily loaded routes as it still takes some time to download the bundle. If you'd like to know more about code-splitting (in React) I can recommend the excellent React documentation.
Demo
I created a simplified example of the code in this post which you can check out here: https://github.com/TimonVS/sync-branch-demo. You can clone it and push a commit to the master branch to see that the shop-staging branch will automatically be kept in sync.
Conclusion
We're quite happy with this approach. GitHub Actions deem to be very flexible. It'd have been even simpler if Netlify would support this use case out of the box, but since that's not the case syncing two branches isn't too bad either.
The approach described in this post can also be used when using split-testing which is built into Netlify and allows you to test two (or more) variants of a website. It's not something we're using ourselves but with split-testing come the same problems as described in the problem statement.
Finally, I have to note that we're currently only using one feature flag. This approach might not scale well if you want to use lots of feature flags because you might want to deploy separate staging sites for all combination of flags.
Happy roasting!
Top comments (3)
Great article!
A note about feature flags: using env vars means that you're missing a very big aspect of feature flags IMO - the ability to release features without building and deploying a new version. If you have a mechanism that allows you to enable feature flags at runtime, you can do things like a/b testing, gradually releasing a feature and in essence you are separating the "feature release" from the application deployment, which can help it become a product decision instead of a technical one.
For example, if you have to coordinate a feature release with a PR announcement, this can help you turn on a feature without the need for deployment.
There are several tools that can help you achieve this:
Thank you for the article!
Hey Oded, this post was meant to demonstrate a very light-weight solution for feature flags. I agree they're not nearly as flexible as using runtime feature flags, but they also introduce downsides like having to pay for a service or hosting your own. This is totally worth it if you're building a (complex) web app though. By the way, with Netlify it's completely possible to do A/B testing by using feature flags in env variables :)
Didn't know about A/B testing env vars with Netlify, very cool! I'll check it out.
Firebase has a free tier if I'm not mistaken and I think AWS also has something. The integration effort is very small and it's worth it even for simpler apps IMO.