<center>
# How to write your own custom `[skip ci]` on GitHub Actions and simplify your life
<big>
</big>
*Written by Cesar Uribe. Originally published 2023-04-04 on the [Monadical blog](https://monadical.com/blog.html).*
*Save time and simplify your life by creating custom filters to skip over [skip ci] in GitHub Actions.*
</center>
Five years ago, GitHub Actions was released to simplify workflows for developers and allow them to integrate the ci/cd pipeline in the same repository without using third-party services. Subsequently, Github implemented the use of a keyword `[skip ci]` within commits to disable the execution of a workflow.
But first, what is `[skip ci]` exactly, and what does it do? Basically, if any commit message in your push or the HEAD commit of your PR contains the strings `[skip ci], [ci skip], [no ci], [skip actions]`, or `[actions skip]`, workflows triggered on the push or pull_request events will be skipped. This is very useful when, for example, you have a workflow that runs tests, which can take minutes, but you only made changes to the README.
As simple as it sounds, this can be simplified even further by creating your own filter that may even be better than `[skip ci]`. This customization allows you to apply the filter to both personalized keywords and the job as well, in the cases where you do not want to omit the execution of the complete workflow but only of certain jobs. These additional functions are not available with the default `[skip ci]` found on GitHub Actions. So if you’re interested in saving time in your execution pipeline and freeing up those credits on GitHub Actions when you run a workflow or job, keep reading! 📖
There are 2 main steps, outlined below:
1. Testing the operation of the default `[skip ci]` on GitHub Actions.
This step will allow you to compare the default `[skip ci]` to the custom `[skip ci]` that you’ll build later. Feel free to skip this part if you’re already familiar with the default `[skip ci]`.
2. Learning how to create your own custom `[skip ci]` using two different methods:
i) Using filters over paths or files
ii) Implementing conditional jobs
Let’s begin!
## Test the operation of `[skip ci]` from GitHub Actions 🧑💻
First, create a [test repository](https://github.com/curibe/gha-custom-filter):
![figure1](https://docs.monadical.com/uploads/2f4b3593-7523-41fc-bed0-dccf612dfc4c.png)
Once you’ve created it, clone it on your local machine.
Then create your first workflow and execute some simple instructions. But remember, this workflow can contain all the jobs that you want to install in your CI/CD pipeline. In the `.github/workflows/`main branch, create the file `workflow1.yml` seen below. Then add it to the stage, commit, and push it to the remote repo:
```yaml
name: Basic info
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
showinfo:
runs-on: ubuntu-latest
steps:
- name: Show message
run: echo "Hello everyone!"
```
This workflow will be activated when two events occur:
* When a push is made to the main branch
* When a pull request is created and pushed
It will also contain a single job that will be in charge of displaying the message in the GitHub Actions display console.
Once the `.yml` file is uploaded, the workflow is immediately activated by the push event:
![figure2](https://docs.monadical.com/uploads/393d2f9f-aebc-4c9a-b383-da8c93936a00.png)
Next, create a `file1.txt` file, and push it to the remote repo in the main branch. This time, in the commit message, add the statement `[skip ci]` at the beginning of the commit:
```bash
touch file1.txt
echo "first line" >> file1.txt
git add file1.txt
git commit -m "[skip ci] add first txt file"
git push origin main
```
Notice that the file was pushed to the repo, but the workflow was not executed:
![figure3](https://docs.monadical.com/uploads/b6dd79a0-6ad6-4b85-9ba2-e8c1e8aeb5c6.png)
![figure4](https://docs.monadical.com/uploads/8a320638-b589-4ebd-abe4-e165cd7302c0.png)
With this, you can see that adding `[skip ci]` in the commit prevents the workflow from running.
This has been tested by pushing the `file1.txt` to the main branch. But what happens when GitHub Flow is implemented, and the event is pushed to a branch related to a Pull Request? Take a look at how that works by doing the following:
1. Create a branch called `pr1` in the local repo:
```bash
git checkout -b pr1
```
2. Create a new file `file2.txt`, and add it to the git stage:
```bash
touch file2.txt
git add file2.txt
```
3. Create a commit with the new file, but add the string `[skip ci]` in the message:
```bash
git commit -m "[skip ci] add file2.txt"
```
4. Push to the remote repository:
```bash
git push origin pr1
```
5. Create the pull request on GitHub:
![figure5](https://docs.monadical.com/uploads/6bf92532-8ef7-4162-b40d-7129420f8778.png)
![figure6](https://docs.monadical.com/uploads/c23a0714-c98a-4a64-ae81-104090668dfb.png)
Once again, you can see the operation of `[skip ci]`. Now, add a line to the `file2.txt` file and do the same process without adding `[skip ci]` to the commit:
```bash
echo "first line in file 2" >> file2.txt
git add file2.txt
git commit -m "add a new line in file2"
git push origin pr1
```
![figure7](https://docs.monadical.com/uploads/b1d9af0c-8b7c-46a4-83b5-3d8d24849788.png)
![figure8](https://docs.monadical.com/uploads/265bfbf0-f2c3-45da-b602-df7c976a5c21.png)
In this case, the workflow was executed without problems. If you merge the PR, you'll notice that the workflow was executed again. This is because the event was pushed to the main branch:
![figure9](https://docs.monadical.com/uploads/dd21d5c3-0feb-4e23-bfc8-e138cd15928a.png)
## Create your own `[skip ci]` 🤔
There are several ways to prevent a pipeline from running, depending on what you need. For example:
1. If you want to prevent the entire workflow from running, you can either use `[skip ci]` or use filters on certain paths or files.
2. If you want to prevent specific jobs of the entire workflow from running, you can implement conditional jobs, which are executed if a certain condition is met.
Let’s look at these two options below in more detail.
### Option 1: Using filters on paths and files 😲
This option allows you to control the execution of the workflow. You can control the execution even if the changes are from a specific path or file related to the commit, but you can't control the `[skip ci]` at the commit level using keywords. For example, the `[skip ci]` occurs at the file and path level. Let’s take a look at how this works:
1. In the main branch, create a folder called content and create the content1.txt inside the folder.
2. Create a file `workflow2.yml` with the following:
```yaml
name: testing filters in gitgub actions
on:
push:
branches:
- main
paths-ignore:
- "content/**"
- "file1.txt"
pull_request:
branches: ["main"]
paths-ignore:
- "content/**"
- "file1.txt"
jobs:
Build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
-name: Show info
run: echo "Hello World"
```
This workflow makes use of the filter [paths-ignore] (https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore) to indicate that said workflow should not be executed when there are changes in `file1.txt`, or if the files are contained in the folder `content`.
3. Add the changes (content file and workflow file) to Git’s stage and commit. Then push it to the main branch of the remote repository.
4. Modify the file `file1.txt`, save changes, and push back to the main branch:
```bash
echo "line 2 in file 1" >> file1.txt
git add file1.txt
git commit -m "add line2 in file1.txt"
git push origin main
```
5. Review the actions executed on GitHub and notice the following:
![](https://docs.monadical.com/uploads/794c1306-1989-4d2f-98f2-dca8db20c7d4.png)
When the first commit is sent, both workflows are executed( red box 1). But when the `file1.txt` file is modified in the second commit, you’ll see that only `workflow1` is executed (red box 2).
**Try it by yourself:**
6. Modify the file `content1.txt` and repeat the normal git process (stage, commit, push) to check what happens in this case.
7. Modify the file `file2.txt`, save changes, and push again to the main branch. In this case, both workflows are executed:
![figure11](https://docs.monadical.com/uploads/f686e749-d9b0-483d-b9ab-2af632abfd6c.png)
There is another drawback in addition to not controlling the `[skip ci]` at the commit level. If you're working on a branch associated with a PR and you modify a file that is not excluded, `workflow2`will remain active. This means it will continue to execute, regardless of subsequent commits that are related to an excluded file. For example, if you create a new PR, `pr2` and modify `file1.txt`, only `workflow1` is executed. If you modify `file2.txt`, both workflows are executed. But if you then modify `file1.txt` again, both workflows are subsequently executed:
![figure12](https://docs.monadical.com/uploads/f2705684-1538-4686-a6a1-0327af99cf93.png)
![figure13](https://docs.monadical.com/uploads/c2ea065c-2d0e-40c7-8741-45e7175a6ac3.png)
![figure14](https://docs.monadical.com/uploads/3ea72f48-21c5-4d30-88fd-2111eae2dedf.png)
In this case, it would be necessary to mix these filters together with `[skip ci]` to avoid the execution of the workflow.
### Option 2: Implementing Conditional Jobs 😎
This second option does not prevent you from running the workflow, but it does allow you to skip some jobs.
Consider a project that has both backend and frontend components within the same repository, and one of the workflow jobs involves running the backend tests. When updating documentation or the README, which doesn't impact the tests, the workflow still executes the tests after pushing the changes, even though the tests code remains unchanged. As a result, you waste execution time running the action pipeline and consume your GitHub Actions account credits. For complex projects, these tests may take a considerable amount of time to finish, making the unnecessary execution even more costly.
In the example above, it would be useful to skip these jobs. You could do this by using your own trigger keywords when you make a push to the remote repository. Let’s learn how to do that by implementing the main branch and PR.
#### Case 1: Main branch
Following the same flow as option 1, complete the next steps:
1. Create the file `workflow3.yml` with the following content:
```yaml
name: filter by jobs
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
showinfo:
runs-on: ubuntu-latest
steps:
- name: Show message
run: echo "Hello everybody!"
- name: show commit message
run: echo "commit - ${{github.event.head_commit.message}}"
test:
if: "!contains(github.event.head_commit.message, '[doc]')"
name: Test
runs-on: ubuntu-latest
steps:
- name: test stuff
run: echo "Simulating tests"
```
In this particular workflow, two jobs have been implemented: `showinfo` displays general information (for example, it can represent a build), and `test` simulates a testing pipeline.
In `showinfo`, a new step has been implemented that allows you to show the last commit of the branch. For that, the GitHub [context](https://docs.github.com/en/actions/learn-github-actions/contexts#github-context) has been used `github.event.head_commit.message`.
In `test`, [conditionals](https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution) have been used in order to evaluate whether or not the last commit contains the string `[doc]` to run that job. If the commit contains the string, the job is not executed.
2. The file is added to the repo and uploaded to GitHub. All workflows are automatically executed, including the new one:
![figure15](https://docs.monadical.com/uploads/2f131bde-3802-484d-b9ef-683802f1ed99.png)
If you review the job `showinfo` in `workflow3`, you’ll see that the used GitHub context returns you the last commit:
![figure16](https://docs.monadical.com/uploads/de53021d-d4de-4bb9-83ec-237409dcd247.png)
In this case, the commit does not contain the string `[doc]`, so the job test is executed.
3. Modify the file `file1.txt` again and upload the changes to GitHub, but add the string `[doc]` in the commit:
![figure17](https://docs.monadical.com/uploads/fcd16721-980e-4cc7-953a-39b576be633c.png)
![figure18](https://docs.monadical.com/uploads/b5ba7ffc-c789-4446-8aab-84396ec272cc.png)
When the `[doc]` string appears in the commit, the job testing is not executed. What does this mean? Well, sometimes you don't need to execute certain jobs, like creation or documentation updates, in your workflows. In this case, implement these filters with your own scopes or trigger keywords. You could even create another job with the function of building the documentation, running it only if you've updated the documentation.
#### Case 2: Pull request
Follow this procedure to test how `workflow3` works in a PR:
1. Create a branch called `pr3` in the local repo.
2. Modify the `file1.txt` file by creating a standard commit and uploading changes.
3. Create the PR in GitHub.
4. Modify the `file1.txt` again. Create a commit by adding the string `[doc]` and upload the change to GitHub.
By following these steps, you get the following:
![figure19](https://docs.monadical.com/uploads/91b00ef7-f617-4f4e-aa6e-31f20f08ab9f.png)
Wait, something happened 😱! The filter didn't work 😭. If we check the `showinfo`, you'll see the reason:
![figure20](https://docs.monadical.com/uploads/205221e7-78bc-46ce-8196-f260996219cc.png)
The GitHub context does not return the commit when working on a PR, so the conditional finds nothing as it receives an empty string. This also executes the `testing` job.
According to the [documentation](https://docs.github.com/en/actions/learn-github-actions/contexts#github-context), `github.event` is described as:
>The full event webhook payload . You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the >workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in "[Events that trigger workflows.](https://docs.github.com/en/articles/events-that-trigger-workflows)" For example, for a workflow run triggered by the [push event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push)
And in this case, the event that triggers the workflow is [pull_request](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request), which does not contain the `head_commit` key in its webhook payload.
To fix this, you need to apply [another strategy]((https://joht.github.io/johtizen/build/2022/01/20/github-actions-push-into-repository.html#git-commit-within-a-pull-request):) to create the filter
* Set the ref parameter of action/checkout to the head of the pull_request to checkout that branch because the ref parameter is different in the push and pull_request event:
> When you use pull_request, `@actions/checkout` will perform a git checkout to the github.ref environment variable. Note that git checkout is not applied to the commit, as it would have been the case when using push.
>
> This difference means that a pull_request workflow ref would look like `refs/remotes/pull/##/merge` whereas a push workflow would be `refs/heads/branch_name`. This explains why the SHA of a push workflow matches the commit that triggered the workflow, whereas the SHA of a pull_request workflow does not; instead the SHA of the pull_request is the resulting commit that was created from merging the base to the head.
>
> <small> Taken from https://frontside.com/blog/2020-05-26-github-actions-pull_request/#how-does-pull_request-affect-actionscheckout </small>
* Use `git log` to pull the latest commit from the branch.
* Use regex to filter messages.
* Share data between jobs. To do this, make use of the statements [set_output](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter) and [needs](https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context) to control the order of execution. This is because you need the result stored in a variable from one job to be used in other jobs, which is a necessary step for GitHub performance reasons. For example, GitHub a-priori configures jobs to be executed in parallel as if they were executed on different machines.
Taking the above into account, create the `workflow4.yml` file with the following content:
```yaml
name: filter by jobs in PR's
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
check_commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.ref }}
-name: check if message contain keywords ace scopeid
id: check_commit
run: |
message=$(git log -1 --pretty=format:'%s')
re="\[(doc|skip-tests)\]"
if [[ $message =~ $re ]]; then
echo "::set-output name=match::true"
echo "$message -> Match is true"
else
echo "$message -> Match is false"
fi
outputs:
match: ${{ steps.check_commit.outputs.match }}
test:
needs: check_commit
if: ${{ needs.check_commit.outputs.match != 'true' }}
name: Test
runs-on: ubuntu-latest
steps:
- name: test stuffs
run: echo "Simulating tests"
```
Notice that in the first step of the `check_commit` job, the ref parameter is set. In the next step, several commands are executed in the shell:
* The commit is extracted with the command `git log -1 --pretty=format:'%s'`.
* Regex is used to validate if the string `[doc]` or `[skip- tests]` is in the commit. With this, you can use more than one scope as a filter in the commit.
* A variable is created with the command `echo "::set-output name=match::true"`, which will be shared between jobs. For this, the key outputs enable the variable to be shared.
* In the job test, the key-value pair `needs: check_commit` indicates that this job depends on check_commit.
* The conditional `if` validates whether the commit has the string `[doc]`, which validates if the match variable is true or not.
Now, push the file `workflow4.yml` to GitHub and modify the file`file1.txt` again. Also, add the string `[doc]` in the commit before pushing it to the GitHub repository. By completing these two operations, the following is obtained:
![figure21](https://docs.monadical.com/uploads/ae199c9a-cf88-47de-8a09-ab2362e90bb0.png)
![figure22](https://docs.monadical.com/uploads/2144645b-14f9-464c-b76a-b32fdc94d311.png)
![figure23](https://docs.monadical.com/uploads/a13f51f4-252c-45b3-9ef9-b52fb8e401ee.png)
The filter has worked using the new workflow 🥳! You can see that the job `testing` is not executed when the string `[doc]` is in the commit. In other words, you’ve implemented your own filter, which allows you to skip the execution of a workflow at the job level.
## Conclusions
You’ve made it this far and now, nothing is stopping you from creating your own custom filters to skip a workflow or to prevent a job from running by using a set of strings like `[skip ci]` on GitHub Actions.
I hope you found these custom filters worthwhile. They should allow you to control and avoid many lengthy and unnecessary executions; this is especially useful when a certain job or workflow takes a while to execute, and forces you to use more credits on GitHub Actions or rely on third-party services.
Let me know in the comments if this approach worked for you. If you found errors or had trouble, please share your concerns below, and I’ll try to help you out!
## References
- https://redactedtech.com/running-conditional-github-jobs-in-sequence/
- https://dev.to/epassaro/use-skip-ci-in-github-actions-1mnf
- https://stackoverflow.com/questions/41565716/how-to-automate-addition-of-skip-ci-to-commit-messages-on-some-bra
- https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
- https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore
- https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
- https://docs.github.com/en/actions/managing-workflow-runs/disabling-and-enabling-a-workflow
- https://docs.github.com/en/actions/learn-github-actions/essential-features-of-github-actions
- https://blog.mergify.com/running-github-actions-only-on-certain-pull-requests/
- https://michaelcurrin.github.io/dev-cheatsheets/cheatsheets/ci-cd/github-actions/triggers.html
- https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
- https://frontside.com/blog/2020-05-26-github-actions-pull_request/#how-does-pull_request-affect-actionscheckout
- https://www.edwardthomson.com/blog/github_actions_15_sharing_data_between_steps.html
- https://www.macstadium.com/blog/sharing-variables-between-jobs-in-github-actions
- https://github.blog/2021-11-18-7-advanced-workflow-automation-features-with-github-actions/
- https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter
- https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
- https://blog.mergify.com/running-github-actions-only-on-certain-pull-requests/
- https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions
<center>
<img src="https://monadical.com/static/logo-black.png" style="height: 80px"/><br/>
Monadical.com | Full-Stack Consultancy
*We build software that outlasts us*
</center>
Recent posts:
- So you want to build a social network?
- Mastering Project Estimation
- Typescript Validators Jamboree
- Revolutionize Animation: Build a Digital Human with Large Language Models
- View more posts...
Back to top