Creating a release with GitLab CI and Composer

CI/CD Oct 07, 2020

If you work with PHP for projects, there's a good chance you work with Composer as a package manager, and therefore have a composer.json file. This is a great place to store the version of your application or package using the version field of the schema. Doing so will mean you manually have to change the version, but doing this as part of the work for a sprint is a small task, and will mean no more worrying about whether all commit messages meet the guidelines for a package to determine the next version number. You also keep full control over your version numbers, and can build release notes to be used as part of the release.

The basic workflow is straight forward. You commit a change to your default branch, it runs the pipeline defined in your .gitlab-ci.yml file and, if needed, creates a new branch and performs the release. The tag and release should always be the final stage of the pipeline.

Before starting, make sure you have a personal access token ready to use. This needs to have api and write_repository access. You can create an access token by going to User Settings and selecting Access Tokens from the menu.

The GitLab personal access token page with 'api' and 'write_repository' permission selected
The GitLab personal access token page

The token generated when "Create personal access token" is pressed should then be added as a CI/CD variable within the project settings. Setting it as protected and masked will ensure it doesn't appear in and console output for the pipeline job

The OAuth2 token generated when creating the personal access token should be added as a CI/CD variable

The .gitlab-ci.yml file to perform this action in isolation with no other pipeline elements is simply:

image: ubuntu:focal

stages:
  - tag

before_script:
  - DEBIAN_FRONTEND=noninteractive apt-get update
  - DEBIAN_FRONTEND=noninteractive apt-get -y install php php-json git openssh-client curl

Tag Release:
  stage: tag
  only:
    - master
  script:
    - VERSION="$(php -r "echo json_decode(file_get_contents('./composer.json'), true)['version'];")";
    - MESSAGE="$(php -r "echo json_encode(['name' => 'Version $VERSION', 'tag_name' => '$VERSION', 'description' => file_get_contents('./changelog/$VERSION.md')]);")";
    - git config --global user.email "your_email"
    - git config --global user.name "username"
    - git remote add api-origin https://oauth2:${OAUTH2_TOKEN}@gitlab.com/PROJECT_PATH
    - >
      if [ $(git tag -l "$VERSION") ]; then
        echo "Version $VERSION already exists"
      else
        git tag -a $VERSION -m "Version $VERSION"
        git push api-origin $VERSION
        curl --header 'Content-Type: application/json' --header "PRIVATE-TOKEN: $OAUTH2_TOKEN" \
           --data "$MESSAGE" \
           --request POST https://gitlab.com/api/v4/projects/PROJECT_PATH/releases
      fi

This requires there to be a folder called changelog and then each release to have its own .md file which is named the same as the version i.e. 0.0.1.md. This allows markup to be used within the release notes.

Breaking down the file gives us the following:

image: ubuntu:focal

stages:
  - tag

before_script:
  - DEBIAN_FRONTEND=noninteractive apt-get update
  - DEBIAN_FRONTEND=noninteractive apt-get -y install php php-json git openssh-client curl

This first part defines the docker image to use, the stages of the pipeline, and then updates Ubuntu (the Docker image used), and installs the basic components we require to undertake the tagging and release.

Tag Release:
  stage: tag
  only:
    - master
  script:

This names the pipeline job "Tag Release", and allocates it to the stage 'tag'. It also tells the pipeline to only run this task on the master branch (you can change that to be any branch you release from). Finally, it leads to the script for the job:

VERSION="$(php -r "echo json_decode(file_get_contents('./composer.json'), true)['version'];")";

This uses PHP to read the composer.json file, and get the value within the version field. It allocates the value to the shell variable $VERSION which is used later.

MESSAGE="$(php -r "echo json_encode(['name' => 'Version $VERSION', 'tag_name' => '$VERSION', 'description' => file_get_contents('./changelog/$VERSION.md')]);")";

This again uses PHP, but this time it builds the cURL data to use later. It uses PHP to build an array and json_encode it so it's the correct format.  The array will look like:

[
  'name' => 'Version $VERSION',
  'tag_name' => '$VERSION',
  'description' => file_get_contents('./changelog/$VERSION.md')
]

Within the array, $VERSION is replaced by the variable already set by the first part of the script. The description element of the array is the full markdown content within the specific version.md file e.d. 0.0.1.md

git config --global user.email "your_email"
git config --global user.name "username"

Configures the git user name and email address to use when tagging a release.

git remote add api-origin https://oauth2:${OAUTH2_TOKEN}@gitlab.com/PROJECT_PATH

This sets a new location to which changes can be pushed. It uses the personal access token created earlier, and set in the CI/CD variable OAUTH2_TOKEN to authenticate the request. The PROJECT_PATH is the username/project-name combined.

Finally, there's the long script part:

>
      if [ $(git tag -l "$VERSION") ]; then
        echo "Version $VERSION already exists"
      else
        git tag -a $VERSION -m "Version $VERSION"
        git push api-origin $VERSION
        curl --header 'Content-Type: application/json' --header "PRIVATE-TOKEN: $OAUTH2_TOKEN" \
           --data "$MESSAGE" \
           --request POST https://gitlab.com/api/v4/projects/PROJECT_PATH/releases
      fi

The > informs the pipeline that this is a multi-line script.

if [ $(git tag -l "$VERSION") ]; then
        echo "Version $VERSION already exists"

This checks with Git whether the version number already exists as a tag within the repository. If it does, it outputs as much to the console and ends the script. This is to prevent failures when trying to create or push a duplicate tag.

 else
        git tag -a $VERSION -m "Version $VERSION"
        git push api-origin $VERSION
        curl --header 'Content-Type: application/json' --header "PRIVATE-TOKEN: $OAUTH2_TOKEN" \
           --data "$MESSAGE" \
           --request POST https://gitlab.com/api/v4/projects/PROJECT_PATH/releases
      fi

If the version does not exist as a tag within the repository, it creates the tag with the version number, and a message stating the version. It then pushes the tag to the repository using the API. This part is important - you cannot simply use git push via SSH for this - pushing changes to the repository from the pipeline via SSH is not allowed (not frowned upon - simply doesn't work).

Once the tag has been created, curl is used to call the API to create the actual release. The $MESSAGE part is the json_encodearray from the start of the script, ready to go. This needs to be surrounded in double quotes ("), as single ones will prevent the variable substitution taking place, and the request will fail. Within the URL to post to, the $PROJECT_PATH is a URL encoded path, to the / which separates the username from the project name must be replaced with %2F.

Working Example

It's great theorising about this working, but putting it into practice is another matter. I've added this to one of my small projects, Age Verification. When I want a new release, I simply bump the version in composer.json and merge a change into master. This particular project has additional elements to the release element of the pipeline:

  1. The release will only take place if the unit tests successfully complete (has dependencies in the .gitlab-ci.yml file
  2. Once the GitLab release has been made, it will submit the changes to Packagist, so the latest version is instantly available to those who need the latest releases of the code.

Tags