Graphics

Preview a Git Branch with shareable links

This article will demonstrate how to use Firebase- preview channels for showing your working branch status to team members before merging.

I have a nice new branch – want to check it out?

The problem

I assume here that you -my dear reader- are already familiar on basic concepts of Git and maybe you use it frequently as a part of your software development process. If not, you can check out an excellent game Oh My Git! to get an idea of the basic concepts.

I have noticed that the release management is always something in the center of the team performance. How do we enable smooth, but controlled releasing to staging and production envs?

First – meet our development team for this article. Alice and Alex are developers in the same website project. Bob is their PO (Product Owner) or customer representative.

The release pipeline is simple – there is a staging-env and a production-env. Both Alice and Alex are aware that things need to be technically working and features need to be tested before releasing.

Alice is about to make a change on the whole layout of the website organizing all the content to be in a nicer form. Now she has implemented and tested all the things in the local env and is ready to push all things to staging. For that, she merges the code to main-branch.

Then she goes on to show the current state of staging-environment to Bob (who – as you remember represents customer for the project). Bob points out couple of issues that need to be ironed out before this feature could be published. This will take Alice a day or so to fix. Git

Now comes Alex.

He has a critical bug fix/update that needs to be pushed to production A.S.A.P.
Failing to do so could lead to some larger issues.
But the release train is currently stuck on station as the staging is locked with Alice’s changes.

Normally this would mean either:

  1. Alice’s half -baked changes and Alex’s changes need both be deployed at the same time. Alice would be working on firefighting mode to release the fixes after this (bad)
  2. Alex pushing changes straight to Production as a HotFix (bad)
  3. Alice (or Alex) rolling back the changes in staging to clear the state for Alex (extra work for Alice/Alex, waiting for Alex – now what if Alex notices another issue after this one…)

As you can see, all of the above options are not-so-good.

Alternative approaches

One possible (and currently very common) solution for this would be Alice using the local env or some lower test env (like dev-env) for showing the changes to Bob. This – however – leads to more issues as with local env Alice and Bob will need to be at least available same time. In case of a separate dev- env the same problem now is transferred to another env – what if Alice and Alex are both working on a large change – Alice changing the layout and Alex updating all dependencies or working on a style-makeover.

Another common strategy is to have a separate release-branch and the development branch is being deployed to one environment and the release-branch is deployed to staging (and later on promoted to production). If the changes get cherry-picked from dev to release things are ok in this aspect. If you – however – have cherry-picked things in Git you might know how it is not suited for careless every-day use and usually this process implies a Release Manager- role.

Third option would be assigning every team member their own dev-env. A non-local env (that can be accessed by Bob). Now – what about Alice now making both the changes for layout and dependencies both in separate branches. Bob takes typically until next day to respond, so it would be nice if Alice could also continue on working on the dependency- changes and show them to Alex while waiting for the feedback on the layout from Bob. So even one person may need more than just one env.

The solution

When Alice opens a Merge Request (or Pull Request) on the changes, there should be some environment corresponding to those changes. This environment should be accessible by Bob. This would untangle Alice’s tasks from any dependencies to any other tasks she or someone else would be doing.

Git

Alice makes the changes in a feature branch and before merging them, she creates a preview channel and shows the implementation to Bob.

This way the staging- env is not blocked by the changes and both Alice and Alex can work on their changes independently until they really need to deploy them to actual staging- and production- environments.

So the release pipeline now contains only the things that are:

  1. Working (as Alice and Alex make sure that tests pass and everything runs smoothly)
  2. The right features (as Bob gets to give feedback on the features before they are merged)

Basically the release to production could be done after any commit – it is just a matter of an agreement on what the release process is and when it is ok to deploy to Production.

Using Firebase web hosting to create Preview Channels

I have been using Google Firebase Hosting for some web- projects. Google has good documentation and a quickstart guide for web hosting.

So how Firebase could help us on solving this issue?
It offers Preview Channels that are suitable for generating dynamically named, fire-and-forget kind of websites. They are content-only so this works for the frontend-app or the website.

You can control firebase either with CLI or through REST API – I’m using CLI for this example. The command to create a preview-channel is:

$ firebase hosting:channel:deploy preview_name

So running this for my test project my-project with a hosting my-project-staging in a git branch my-feature in my local machine results in a log like:

$ firebase hosting:channel:deploy branch-my-feature

=== Deploying to 'my-project'...

i  deploying hosting
i  hosting[my-project-staging]: beginning deploy...
i  hosting[my-project-staging]: found 303 files in public
✔  hosting[my-project-staging]: file upload complete
i  hosting[my-project-staging]: finalizing version...
✔  hosting[my-project-staging]: version finalized
i  hosting[my-project-staging]: releasing new version...
✔  hosting[my-project-staging]: release complete

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/my-project/overview
Hosting URL: https://my-project-staging.web.app

✔  hosting:channel: Channel URL (my-project-staging):
  https://my-project-staging--branch-my-feature-omgmag1caly.web.app
  [expires 2022-08-01 14:21:39] [version deadbeef00ba2]

Now pointing the browser to the url shows the branch state just nicely.
This link is now valid for 7 days and I can manage the actual expiration on Firebase-console.

Making the right thing easy

My example below is using GitLab-CI – but this is can be adopted to other CI:s as well. There is documentation available also for GitHub.

GitLab-CI is container based and the main file is usually .gitlab-ci.yml placed in your project-root. Originally the file could look something like this:

#.gitlab-ci.yml
image: node-17-alpine3.14 # Here is the base-image for the default & build steps

stages:
  - build
  - deploy

cache: &global_cache
  key: my-global-cache-key-here
  paths:

build-staging:
  stage: build
  only:
    - staging
  script:
    - node build env:staging #here the actual build steps
  artifacts:
    name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
    paths:
      - public # this directory gets stored as the build artifacts

deploy-staging:
  image: andreysenov/firebase-tools:10.7.2-node-16-alpine # base-image with tools
  stage: deploy
  environment:
    name: staging
    url: https://my-project-staging.web.app
  only:
    - staging
  dependencies:
    - build-staging
  cache:
    <<: *global_cache
    policy: pull
  script:
    - firebase use --token $FIREBASE_TOKEN $FIREBASE_PROJECT #This comes from variables
    - firebase deploy --only hosting:staging -m "Pipeline $CI_PIPELINE_ID, build $CI_BUILD_ID" 
       --non-interactive --token $FIREBASE_TOKEN

# Here continues the production part (mostly copy-and-paste from above)

So this file contains the steps and instructions for building and deploying the build to a staging env. For clarity I left out production steps, which are similar to staging.

First we need to make sure that the deployment of these channels are only available in staging or dev env (and not pushed directly to production). This can be achieved by adding --only staging (where staging is the name of the hosting-environment in your firebase.json) to the channel-deploy command.

For additional safety you could use different credentials or split your firebase.json to nonprod & prod versions so that if someone deploys a preview-channel from local env it would by default only deploy non-prod versions. Basically you would use something like firebase-prod.json for production and pass that using --config– flag on any commands.

In GitLab CI there is a separate documentation for building Merge Request-pipelines. Especially we are interested on Merge Result pipeline which is a subtype. The idea here is to make a build containing both the target-branch changes and source branch changes. So it pretty much simulates the situation after the current commits in the source branch would have been merged with the target branch.

So you could add something like this to your .gitlab-ci.yml:

#.gitlab-ci.yml
#...
build-merge-request:
  stage: build
  only:
    - merge_requests
  script:
    - node build env:preview-branch #here the actual build steps
  artifacts:
    name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
    paths:
      - public

deploy-merge-request:
  image: andreysenov/firebase-tools:10.7.2-node-16-alpine
  stage: deploy
  environment:
    name: preview
  only:
    - merge_requests
  dependencies:
    - build-merge-request
  cache:
    <<: *global_cache
    policy: pull
  script:
    - firebase use --token $FIREBASE_TOKEN $FIREBASE_PROJECT
    - firebase hosting:channel:deploy branch-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME --only staging --non-interactive --token $FIREBASE_TOKEN

#...

As you commit this change to your GitLab- project, push the branch and make a merge request you should see the pipeline starting.

Now you need just to open the pipeline -log and find the preview-env link from there. Nice.

Sometimes you do, sometimes you don’t

Now – this makes the Merge-Request pipeline to build the preview channels every time. Sometimes you might be just making a quick change and might not want to wait extra time for the build to finish.

Basically what we need is a way on a particular Merge Request to control whether or not to execute these steps. Easiest way for me was to make a label in the GitLab CI Merge Reqests. I made a label with the text Preview-Channel for the purpose of controlling the build.

Now we can just simply test if the list of the labels contains this label like this (note that this is shortened for brevity):

#.gitlab-ci.yml
#...
build-merge-request:
#...
  script: |
    if [[ ",$CI_MERGE_REQUEST_LABELS," = *",Preview-Channel,"* ]]; then
      echo "Building Preview Channel-env for Merge Request"
      # the actual build steps go here
    fi
#...

deploy-merge-request:
#...
  script: |
    if [[ ",$CI_MERGE_REQUEST_LABELS," = *",Preview-Channel,"* ]]; then
      echo "Deploying Preview Channel-env for Merge Request"
      # the actual deployment steps go here
    fi
#...

If your test-condition would be more complex, you might want to define a variable in a before-script and test that on the actual condition – see this nice trick.

Where to find the env?

Now – it would be nice if ‘Bob’ (who is not tech savvy with tools like Git & Firebase console) could see my changes in action somehow straight from the Merge Request. We might want to add a link there.

There is Notes API available in GitLab. Notes for Merge Requests can be created via POST message to /api/v4/projects/:project_id/merge_requests/:merge_request_iid/notes/

Notes API requires authentication. At least on my version I was not able to use CI_JOB_TOKEN for that purpose.
I added a new token for my GitLab-user account and configured that on the GitLab job variables GITLAB_API_ACCESS_TOKEN
as in the screenshot. The token I created using https://my-gitlab-domain/profile/personal_access_tokens -page. Note that the token value is visible only on creation – store it safely. I named this token GITLAB_API_ACCESS_TOKEN and gave it access (scope) only to API.

Now – how to get the url for the env programmatically? Firebase provides firebase hosting:channel:list and firebase hosting:channel:open --non-interactive. Both of them output nice, user friendly output. For scripting – however – a plain JSON or some other formatted text would be nicer. Maybe without control characters. In the script below there is some sed-grep-tr -magick for making the list produced by hosting:channel:list a markdown table. This way you can see straight away, if you have fresh or stale fish in the Preview-Channel related to your Merge Request.

Next we make an artifact out of the channel info (which is written to a simple text file) and add another step for publishing the info as a comment on the Merge Request. Alternatively you could use deployment-container image containing curl & jq, if one is available to you or if you want to maintain your own for the purpose.
Note that whenever there is any updates on the Merge Request, there already may be existing preview-channel-info – comment. In this case we update that comment rather than publish a new one every time. Updating can be achieved by using PUT instead of POST (note below some differences in path & message body).

So for consistency – here is the full solution for the Merge Request deployment and post-steps:

#.gitlab-ci.yml
#...
deploy-merge-request:
  image: andreysenov/firebase-tools:10.7.2-node-16-alpine
  stage: deploy
  environment:
    name: preview
  only:
    - merge_requests
  dependencies:
    - build-merge-request
  artifacts:
    name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
    paths:
      - preview_channel_info
  script: |
    if [[ ",$CI_MERGE_REQUEST_LABELS," = *",Preview-Channel,"* ]]; then
      echo "Deploying Preview Channel-env for Merge Request"
      firebase use --token $FIREBASE_TOKEN $FIREBASE_PROJECT
      firebase hosting:channel:deploy branch-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME --only staging --non-interactive --token $FIREBASE_TOKEN
      firebase hosting:channel:list  --non-interactive --site my-project-staging  --token $FIREBASE_TOKEN
      PREVIEW_CHANNEL_INFO=$(firebase hosting:channel:list --site my-project-staging | grep -e "Channel ID" -e  branch-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME -A1 | sed -e s/[│├┼┤]/|/g -e s/─/-/g -e '$ d' | grep | | sed 's/$/\n/g' | tr -d 'n')
      echo $PREVIEW_CHANNEL_INFO > preview_channel_info
    else
      echo "Skipping Preview channel - deployment"
    fi

post-deploy-merge-request:
  image: dwdraju/alpine-curl-jq
  stage: post-deploy
  only:
    - merge_request
  dependencies:
    - deploy-merge-request
  cache:
    <<: *global_cache
    policy: pull

  script: |
    if [[ ",$CI_MERGE_REQUEST_LABELS," = *",Preview-Channel,"* ]]; then
      PREVIEW_CHANNEL_INFO=$(cat preview_channel_info)
      NOTES_API_URL="$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
      NOTE_ID=$(curl -f --header "PRIVATE-TOKEN: ${GITLAB_API_ACCESS_TOKEN}" "$NOTES_API_URL" | jq 'first(.[] | select(.body|test("Preview-Channel.*")) | .id)')
      NOTE_BODY=$(echo -e "Preview-Channel deployment info:nn$PREVIEW_CHANNEL_INFO")
      if [ -z $NOTE_ID ]; then
        NOTE_JSON=$(jq --null-input --arg id "$CI_PROJECT_ID" --arg iid "$CI_MERGE_REQUEST_IID" --arg body "$NOTE_BODY"  '{"id": $id, "iid": $iid, "body": $body}')
        echo $NOTE_JSON
        curl -f --header "PRIVATE-TOKEN: ${GITLAB_API_ACCESS_TOKEN}" --header "Content-Type: application/json" -X POST "$NOTES_API_URL" -d "$NOTE_JSON"
      else
        NOTE_JSON=$(jq --null-input --arg id "$CI_PROJECT_ID" --arg iid "$CI_MERGE_REQUEST_IID" --arg note_id "$NOTE_ID" --arg body "$NOTE_BODY"  '{"id": $id, "iid": $iid, "note_id": $note_id, "body": $body}')
        echo $NOTE_JSON
        curl -f --header "PRIVATE-TOKEN: ${GITLAB_API_ACCESS_TOKEN}" --header "Content-Type: application/json" -X PUT "${NOTES_API_URL}/${NOTE_ID}" -d "$NOTE_JSON"
      fi
    else
      echo "Skipping Preview channel - manifest"
    fi
#...

GitLab Once the GitLab CI pipeline has been run successfully, you should see a comment like this in your merge-request page at GitLab.

Now you can simply click the link in the comment and see what the changes for this particular Merge Request would look like.

Summary and final thoughts

In the above example we walked through on how to implement preview-channels pattern with GitLab and Firebase hosting.
If you need to do a chain of components depending on one-another all having changes this might not be enough.

On the frontend you can variate on what backend you connect to. So in the case your backend would support similar pattern, you could point your staging and production frontends to respective backends.
Similarily with the preview-channels you could use some naming convention for your branches accross repositories and expose your apis in a way compatible with this pattern.
This – however – goes well beyond the scope of this simple example.

Thank you taking your time on reading this article. Feel free to link to it if you saw it helpful.

Update on GCP Authentication

In the above we have used TOKEN AUTHENTICATION for GCP. Later on, after updating the dependencies we came across a warning-message from GCP CLI.

Authenticating with `--token` is deprecated and will be removed in a future major version of `firebase-tools`. Instead, use a service account key with `GOOGLE_APPLICATION_CREDENTIALS`: https://cloud.google.com/docs/authentication/getting-started

So instead of the token authentication (which is bound to a single developer or role account) we should use special service account. I’m not going to go this fully through but the following excerpt is the corrected deployment step for the channels if you have the service account otherwise in place.

  # FIREBASE_SERVICE_ACCOUNT variable contains json object for service account authentication
  # Dump the json to local /tmp/- file and use that for auth. Cleanup after the job.

  echo $FIREBASE_SERVICE_ACCOUNT > /tmp/firebase_service_account-$CI_JOB_ID.json
  export GOOGLE_APPLICATION_CREDENTIALS=/tmp/firebase_service_account-$CI_JOB_ID.json
  firebase deploy hosting:channel:deploy branch-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME --only staging --non-interactive
  rm /tmp/firebase_service_account-$CI_JOB_ID.json

See also this helpful Stack Overflow-article about Firebase Tools authentication with a GCP Service Account.

Instructions on how to generate the JSON-object (key) required for authentication can be found here.

There is more