Deploying to Firebase Preview Channels using CircleCI and GitHub

2nd Jan, 2023

If you use Firebase to host your website, you may have seen the new Preview Channels functionality has entered Beta and wondered - what on earth is that?

I've recently been using Renovate to automatically handle dependency updates, and wanted a way to preview the automatically proposed changes without having to merge into staging and do that dance. Preview channels to the rescue!

This guide combines a few ideas:

What is Firebase?

Let's start at the beginning - Firebase is Google's free-to-use (up to a point) suite of Web application tools. Most notably (along with Firestore) is its hosting capability, which means given a set of files you can deploy a static application incredibly easily (note: there are also cloud functions and edge services to make it not static, but that's outside the realms of this post).

How do you get set up with Firebase?

As a quick start, you can't get better than the Get started with Firebase Hosting page from Google which explains all the basics of getting set up with Firebase Hosting, that I won't repeat here as there's nothing I can add!

What is CircleCI?

CircleCI is deployment pipeline (aka CI/CD) platform, which is also free-to-use up to a point (with very generous allowances for Open Source projects), and is my pipeline of choice due to its usage in Enterprise spaces, which means using it in personal projects grants me experience with things I do for "proper work".

It also helps that it's fantastically configurable, fast, and y'know… free!

How do you get set up with CircleCI?

CircleCI has a great Getting started guide which should get you through the initial hoops of getting your repository building from GitHub, with some great initial templates for whatever your application needs.

This website uses it, so if you want to just see the output and you're confident with Continuous Deployment pipelines, go check out its CircleCI config.

What are Firebase Preview Channels?

Firebase preview channels create a temporary version of your hosted application with some identifier available so you can "preview" any proposed changes. For example with this website, I build any PRs I raise on GitHub to a preview channel so I can check everything is working as expected and nothing has broken silently in the build process.

So, when I raise a PR CircleCI builds it, and asks Firebase to deploy it to a preview channel. I then post this URL back to GitHub so I can see it for real!

screenshot of github comment on a pull request

How to configure Firebase preview channels?

So now you've followed the Get started with Firebase Hosting and Getting started [with CircleCI] guides, and you've got a deployment pipeline for your application - well done! Now let's do something cool.

Configure your CI/CD

Your CI/CD of choice should have a capability to ensure some steps only get run under certain conditions. For example on CircleCI for this very website, I have:

# YAML

# Jobs not listed for brevity

workflows:
  build_and_deploy:
    jobs:
      - install_deps
      - lint:
          requires:
            - install_deps
      - build_dev:
          filters:
            branches:
              ignore: master
          requires:
            - install_deps
      - build_prod_with_version:
          filters:
            branches:
              only: master
          requires:
            - install_deps
      - deploy_to_firebase_prod:
          filters:
            branches:
              only: master
          requires:
            - lint
            - build_prod_with_version
      - deploy_to_firebase_preview:
          filters:
            branches:
              ignore:
                - master
                - staging
          requires:
            - lint
            - build_dev
      - deploy_to_firebase_stg:
          filters:
            branches:
              only: staging
          requires:
            - lint
            - build_dev

This config means that if I push to these different branches, then these jobs run:

As an example of a non-preview deploy, the deploy_to_firebase_prod step is super simple:

#YAML

jobs:
  deploy_to_firebase_prod:
    executor: node-project
    steps:
      - checkout
      - restore_cache: # special step to restore the dependency cache
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - restore_cache: # restore the /public folder and associated deploy files
          key: deploy-cache-{{ .Environment.CIRCLE_WORKFLOW_ID }}
      - run:
          name: Deploy to Firebase Production Hosting
          command: ./node_modules/.bin/firebase deploy --token "$FIREBASE_TOKEN" --only hosting:production

First off this step restores some caches from the install_deps and build_prod_with_version steps, before attempting to deploy to Firebase. You can see that I have to pass in --token "$FIREBASE_TOKEN" to authenticate (so I've added FIREBASE_TOKEN as an environment variable for the pipeline), as well as --only hosting:production to let Firebase know which platform I want to deploy to (as I have production and staging configured).

I also prefer to use the project-installed version of firebase rather than do any npm i -g firebase-tools shenanigans, as it keeps the firebase version locked with the rest of the project, which prevents unexpected problems if firebase were to upgrade unexpectedly.

Deploying to Firebase Preview channel

So you're now deploying to Firebase for your "normal" deployments - production, staging, whatever. But let's get preview channels enabled! Let's see what a simple step might look like:

jobs:
  deploy_to_firebase_preview:
    executor: node-project
    steps:
      - checkout
      - restore_cache: # special step to restore the dependency cache
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - restore_cache: # restore the /public folder and associated deploy files
          key: deploy-cache-{{ .Environment.CIRCLE_WORKFLOW_ID }}
      - run:
          name: Deploy to Firebase Preview Channel
          command: ./node_modules/.bin/firebase hosting:channel:deploy $CIRCLE_BRANCH --token "$FIREBASE_TOKEN"

Great, we've now got preview channels deploying - it really is that easy! As the "unique ID" we're using the $CIRCLE_BRANCH so that we don't create endless preview channels if the same branch gets updated. Note that you don't need the --only hosting:x parameter, as preview channels are always only for hosting.

Posting a comment back to GitHub

But what if we want to post a GitHub comment on the PR that accompanies the branch? Then as an extra run step we could add:

      - run:
          name: Post Github PR Comment
          command: |
            sudo apt-get install jq

            channels=$(./node_modules/.bin/firebase hosting:channel:list)
            circle_branch_replaced=$(echo $CIRCLE_BRANCH | sed "s/\//-/")
            regex='(https:\/\/[a-z0-9-]*--'"${circle_branch_replaced:0:39}"'-[a-z0-9-]*.web.app)'
            [[ $channels =~ $regex ]] && url=${BASH_REMATCH[0]}

            if [ $(echo $url | jq length) -eq 0]; then
              url="Unable to get URL - check Firebase console"
            fi

            pr_response=$(curl --location --request GET "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls?head=$CIRCLE_PROJECT_USERNAME:$CIRCLE_BRANCH&state=open" \
            -u $GH_USER:$GH_TOKEN)

            if [ $(echo $pr_response | jq length) -eq 0 ]; then
              echo "No PR found to update"
            else
              pr_comment_url=$(echo $pr_response | jq -r ".[]._links.comments.href")
            fi

            curl --location --request POST "$pr_comment_url" \
            -u $GH_USER:$GH_TOKEN \
            --header 'Content-Type: application/json' \
            --data-raw '{"body": "Successfully deployed to Firebase preview channel! Available at: '"$url"'"}'

This step does a few things, and begins with some set up and variable assigning.

sudo apt-get install jq installs the jq package to enable some functions we want to use in the script.

channels=$(./node_modules/.bin/firebase hosting:channel:list) assigns a variable called $channels with a value of all the different preview channels that are currently live that Firebase is tracking. Assuming this command is run after a successful firebase hosting:channel:deploy run, this will include the most recently-deployed channel.

circle_branch_replaced=$(echo $CIRCLE_BRANCH | sed "s/\//-/") is replacing any forward slashes found in $CIRCLE_BRANCH with dashes, just like firebase will do if you pass in anything with slashes.

regex='(https:\/\/[a-z0-9-]*--'"${circle_branch_replaced:0:39}"'-[a-z0-9-]*.web.app)' creates a regex that looks for the URL that looks like the channel you've just deployed. Firebase also only uses the first 40 characters of the ID (which is what "${circle_branch_replaced:0:39}" is doing). So our regex ends up something like:

(https:\/\/[a-z0-9-]*--mybranch-something-[a-z0-9-]*.web.app)

Don't forget the parenthesis around it so you create a regex capture group!

[[ $channels =~ $regex ]] && url=${BASH_REMATCH[0]} uses the $channels and $regex variables we've defined above, and runs the regex against the channel list. It then assigns a variable called $url with the matched URL in a capture group.

The if [ $(echo $url | jq length) -eq 0]; then statement checks that we've actually found a URL by asserting the length of $url is greater than zero - if it's not, we assign an error message to the $url variable.

Next up, we assign a curl target to the variable $pr_response. For this we need to create two environment variables for our pipeline: GH_USER and GH_TOKEN. GH_USER is your GitHub username, and GH_TOKEN is a personal access token with read/write permissions for pull requests on the associated repository. We don't need to worry about any of those variables used prefixed with $CIRCLE_ because they're all available to us for free!

We then check if we found a relevant PR using the if [ $(echo $pr_response | jq length) -eq 0 ]; then block, using jq to check the length as before. If we find one, we assign $pr_comment_url the URL we need to hit to add a comment.

Finally, we perform the curl request to add the comment, using:

curl --location --request POST "$pr_comment_url" \
  -u $GH_USER:$GH_TOKEN \
  --header 'Content-Type: application/json' \
  --data-raw '{"body": "Successfully deployed to Firebase preview channel! Available at: '"$url"'"}'

Be very careful when constructing your --data-raw - the way variable interpolations work in bash is confusing, so be sure to terminate your strings either side of it to not have any weirdness. You can see my doing this by doing:

# Bash will concatenate strings if side-by-side
some_var=hello
result='some "text" with double-quotes in it '"$some_var"' more text'
echo $result
# some "text" with double-quotes in it hello more text

Final Step Code

Et voila! Putting it all together we have:

jobs:
  deploy_to_firebase_preview: # deploy the project to preview channel - SHOULD ONLY BE RUN ON PR BRANCHES
    executor: node-project
    steps:
      - checkout
      - restore_cache: # special step to restore the dependency cache
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - restore_cache: # restore the /public folder and associated deploy files
          key: deploy-cache-{{ .Environment.CIRCLE_WORKFLOW_ID }}
      - run:
          name: Deploy to Firebase Preview Channel
          command: ./node_modules/.bin/firebase hosting:channel:deploy $CIRCLE_BRANCH --token "$FIREBASE_TOKEN"
      - run:
          name: Post Github PR Comment
          command: |
            sudo apt-get install jq

            channels=$(./node_modules/.bin/firebase hosting:channel:list)
            circle_branch_replaced=$(echo $CIRCLE_BRANCH | sed "s/\//-/")
            regex='(https:\/\/[a-z0-9-]*--'"${circle_branch_replaced:0:39}"'-[a-z0-9-]*.web.app)'
            [[ $channels =~ $regex ]] && url=${BASH_REMATCH[0]}

            if [ $(echo $url | jq length) -eq 0]; then
              url="Unable to get URL - check Firebase console"
            fi

            pr_response=$(curl --location --request GET "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls?head=$CIRCLE_PROJECT_USERNAME:$CIRCLE_BRANCH&state=open" \
            -u $GH_USER:$GH_TOKEN)

            if [ $(echo $pr_response | jq length) -eq 0 ]; then
              echo "No PR found to update"
            else
              pr_comment_url=$(echo $pr_response | jq -r ".[]._links.comments.href")
            fi

            curl --location --request POST "$pr_comment_url" \
            -u $GH_USER:$GH_TOKEN \
            --header 'Content-Type: application/json' \
            --data-raw '{"body": "Successfully deployed to Firebase preview channel! Available at: '"$url"'"}'

And now we have PRs (well, any branches that aren't master or staging) deploying to preview channels and posting back to the relevant PR with a comment to view the changes.

To see the entire build step for this website, check out its CircleCI config. It's not doing anything crazy - install dependencies, lint the repo, build the site, and deploy!

1 comments

Thanks for reading! Fancy sharing...?

More thoughts