GitLab CI, Semantic Release, Environment Branches and Continuous Deployment

Consider a setup with 4 environments, dev, test, acceptance and production. Each environment is tied to a branch. The full versioning and deployment is managed via GitOps.

The dev branch is the first in line; when a commit is made, a pipeline will start, which does a few things:

  1. Runs the normal test and build suite to validate the code.
  2. Runs Semantic Release, which...
    1. Checks if any semantic commits have been made. If not, it will skip the rest of the pipeline.
    2. Bumps up the version. (e.g. in the package.json file)
    3. Creates an entry in the changelog.
    4. Pushes a previously built image tagged with the new version to the registry.
    5. Updates the version on the dev branch using a commit (which will trigger a deployment to the dev environment).
  3. Creates a pull request to merge the dev branch into the test branch (unless there is already a pull request open).

When the developer decides that the version is ready to be released on the test environment, they will merge the dev branch into the test branch. This will also trigger a pipeline, which does only 1 thing:

  1. Creates a pull request to merge the tst branch into the acceptance branch (unless there is already a pull request open).

Indeed, it is not necessary to run the test suite again, since the code has already been tested, build and versioned when it was merged into the dev branch.

The same holds true for the acceptance branch, which will create a pull request to merge into the production branch.

Why use Semantic Release?

It builds the image before bumping up the version in the branch. This means there will always be an image available with the version specified in the branch. This is not the case if you build the image after you have bumped up the version.

If you are using external continuous deployment tool that is based on GitOPS principles, this difference is quite important. If you commit a bumped version without building an image, the deployment tool will not be able to find the image.

Creating a Pull Request to Merge a Branch into Another Branch

Here is some code for the last part, which will create a pull request to merge the dev branch into the test branch:

const gitlabId = process.env.CI_PROJECT_ID;
const url = `https://gitlab.com/api/v4/projects/${gitlabId}/`;

const token = process.env.GITLAB_TOKEN;
if (!token) {
  console.error("No GITLAB_TOKEN found");
  const is_protected = process.env.CI_COMMIT_REF_PROTECTED === "true";

  console.error(
    `The token may not be set in the environment variables. You can set the token in the CI/CD settings of the project.`,
  );
  console.error(
    `NOTE: This branch is ${
      is_protected ? "" : "not "
    }protected. Depending on the settings of the token, it may not be available in unprotected branches.`,
  );
  process.exit(1);
}

const source_branch = process.env.CI_COMMIT_REF_NAME!;

const branch_map: {
  [key: string]: string | undefined;
} = {
  dev: "tst",
  tst: "acc",
  acc: "main",
};

const target_branch = branch_map[source_branch];

if (!target_branch) {
  console.error(`No target branch found for ${source_branch}`);
  process.exit(1);
}

const current_merge_requests = await fetch(
  `${url}merge_requests?state=opened&source_branch=${source_branch}&target_branch=${target_branch}`,
  {
    headers: {
      "PRIVATE-TOKEN": token,
    },
  },
).then((res) => res.json());

if (current_merge_requests.length > 0) {
  console.log(
    `There is already an open merge request for ${source_branch} to ${target_branch}`,
  );
  console.log(`Merge request: ${current_merge_requests[0].web_url}`);
  process.exit(0);
}

const new_merge_request = await fetch(`${url}merge_requests`, {
  method: "POST",
  headers: {
    "PRIVATE-TOKEN": token,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    id: gitlabId,
    source_branch,
    target_branch,
    remove_source_branch: false,
    title: `Release ${source_branch} to ${target_branch}`,
    assignee_id: process.env.GITLAB_USER_ID,
  }),
}).then((res) => res.json());

console.log(`Created merge request ${new_merge_request.web_url}`);

Running the script

To run this script on Gitlab CI, we use bun. Bun is similar to nodejs, but faster. It is also able to execute typescript code. Thus, we can get the benefits of typescript while not having to set up complex build pipelines.

This script is only relevant for the dev, test and acceptance branches, since the production branch is the last in line. Thus, we filter the branches using the if statement.

post_release:
  image: oven/bun:0.5.6
  stage: post_release
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^(dev|tst|acc)$/
  script:
    - bun ./scripts/mr.ts

Running Semantic Release

For completeness, here is the part which will run semantic-release. The image is a custom image which contains the semantic-release cli (including plugins). The rules.if statement will make sure that semantic-release will only run if the commit message does not contain [semantic-release]. Furthermore, the pipeline will only run if the commit is made to the dev branch.

release:
  image: some-semantic-release-image
  stage: release
  rules:
    - if: $CI_COMMIT_BRANCH == "dev" && $CI_COMMIT_MESSAGE !~ /(\[semantic-release\])/
  needs:
    - job: build
      artifacts: true
  before_script:
    - echo -n $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker build -t my-project-image .
  script:
    - npx semantic-release

Configuring Semantic Release

The commit message of semantic release is to be configured in the .releaserc.json file. We use the [semantic-release] tag instead of the default [skip ci] tag, since we still want to trigger the pipeline. Why we still want to trigger the pipeline: fast-forward merges will otherwise not create new pull requests. For example, merging dev into test should create a pull request to merge test into acceptance. So, instead of skipping everything, we can choose what to skip with the rules.if statement.

{
  "branches": ["dev"],
  "plugins": [
    [
      "@semantic-release/git",
      {
        "message": "chore(release): ${nextRelease.version} [semantic-release]\n\n${nextRelease.notes}"
      }
    ]
  ]
}

Configuring Gitlab CI

Another point of notice is the ability to skip pipelines that have already run on the dev branch. The built artifact will not change, so there is no need to run the test, build and release stages again. This can also be done with the rules.if statement.

rules:
  - if: $CI_COMMIT_BRANCH !~ /^(tst|acc|main)$/ && $CI_COMMIT_MESSAGE !~ /(\[semantic-release\])/ && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME !~ /^(tst|acc|main)$/

Or, more readable, a job should run if all the following conditions are met:

  • $CI_COMMIT_BRANCH !~ /^(tst|acc|main)$/: The branch which triggered the pipeline is not in the list of branches that already has an artifact built prevously. If there is no branch (the pipeline was triggered by a merge request), this conditional will be true.
  • $CI_COMMIT_MESSAGE !~ /(\[semantic-release\])/: The commit was not made by semantic-release.
  • $CI_MERGE_REQUEST_TARGET_BRANCH_NAME !~ /^(tst|acc|main)$/: The merge request was not made to a branch that already has an artifact built previously. Since we can only go from dev to test to acceptance to production, any of these branches will have an artifact built previously.
CSS Specificity ReductionSonar: Could not find a default branch to fall back on error