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:
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:
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.
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.
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}`);
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
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
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}"
}
]
]
}
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.