- A GitHub account, with a functioning SSH-key or password setup
- Available GitHub action minutes and storage space (included in free tier)
The tasks in the workshop can be done using only the built-in GitHub editor. However, in order to learn about supporting tooling, and simplify some tasks, the workshop will assume you've installed the following tools:
- Your preferred terminal emulator/shell and
git
- The GitHub CLI
- actionlint
- ShellCheck (will be used by actionlint)
- Editor with YAML and GitHub actions plugins (e.g., VS Code with the YAML and GitHub Actions extensions)
Start by creating your own fork of this repository. If you've installed gh
you can run gh repo fork --clone bekk/github-actions-workshop
to create your own fork of this repository and clone it to your machine. Run gh auth
first if you're using gh
for the first time. Otherwise, use the GitHub UI to fork this repository. If you're reading the tasks in the browser, use the forked repo so that relative links work correctly.
This repository contains a simple go app. You do not need to know go, nor use any Golang tooling. We will, unless explicitly specified otherwise, only modify files in the special .github/
directory.
Tip
The Workflow syntax for GitHub Actions is handy if you're not sure how something works.
-
We'll start with a simple workflow. Create the file
.github/workflows/test.yml
with the following content:# The "display name", shown in the GitHub UI name: Build and test # Trigger, run on push on any branch on: push: jobs: test: # The 'build' job name: "Build application" runs-on: 'ubuntu-latest' steps: # Step to print a simple message - run: echo "Hello world"
.github/workflows/
is a special directory where all workflows should be placed. -
(Optional) Before committing and pushing, run
actionlint
from the repository root. It should run with a zero (successful) exit code and no output, since the workflow file is without errors. Try again withactionlint --verbose
and verify the output to confirm that it found your file. By default,actionlint
scans all files in.github/workflows/
-
Commit and push the workflow to your fork. In the GitHub UI, navigate to the "Actions" tab, and verify that it runs successfully.
Note
How does this work?
Let's break down the workflow file.
name:
is only used for display in the GitHub UIon:
specifies triggers - what causes this workflow to be runjobs:
specifies each job in the workflow. A job runs on a single virtual machine with a given OS (here:ubuntu-latest
), and thesteps
share the environment (filesystem, installed tools, environment variables, etc.). Different jobs have separate environments.steps:
run sequentially, and might run shell scripts or an action (a reusable, pre-made piece of code). Each step can run conditionally. If a step fails, all later steps fail by default. Creating steps to do e.g. cleanup in error situations is possible.
-
Let's use some pre-made actions to checkout our code, and install Golang tooling. Replace the "hello world" step with the following steps:
# Checkout code - uses: actions/checkout@v4 # Install go 1.21 - name: Setup go uses: actions/setup-go@v4 with: # Specify input variables to the action go-version: '1.21.x' # Shell script to print the version - run: go version
-
Again, run
actionlint
before you commit and push. Verify that the correct version is printed. -
Continue by adding steps to build and test the application:
- name: Build run: go build -v ./... - name: Test run: go test ./...
-
Verify that the workflow fails if the build fails (create a syntax error in any file). Separately, verify that the workflow fail when the tests are incorrect (modify a test case in
internal/greeting/greet_test.go
).
-
A
Dockerfile
defining the application image exists in the root directory. To do a container-based deploy we'll use the actions provided by Docker to build the image. Create.github/workflows/build.yml
with the following content:on: push jobs: build: runs-on: 'ubuntu-latest' steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image uses: docker/build-push-action@v5 with: push: false tags: ghcr.io/${{ github.repository }}:latest
Note
The ${{ <expression> }}
syntax is used to access variables, call functions and more. You can read more in the documentation.
In this case, ${{ github.repository }}
is a variable from the github
context refers to the owner or and repos, meaning the Docker image will be tagged with ghcr.io/<user-or-org>/<repo-name>:latest
.
-
Push and verify that the action runs correctly.
-
In order to push the image, we will need to set up permissions. With
packages: write
you allow the action to push images to the GitHub Container Registry (GHCR). You can set it at the top-level, for all jobs in the workflow, or for a single job:jobs: build: permissions: packages: write # ... runs-on, steps, etc
-
We'll have to add a step the
docker/login-action@v3
action to login to GHCR, before we push it. Add the following step before the build and push step:- name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }}
Note
The github.token
(often referred to as GITHUB_TOKEN
) is a special token used to authenticate the workflow job. Read more about it here in the documentation.
-
Finally, modify the build and push step. Set
push: true
to make the action push the image after it's built. -
Push the changes and make sure the workflow runs successfully. This will push the package to your organization's or your own package registry. The image should be associated with your repo as a package (right-hand side on the main repository page).
Jobs in the same workflow file can be run in parallel if they're not dependent on each other.
-
We also want to lint the app code. In order to get faster feedback, we can create a separate lint-job in our
test.yml
workflow file. Create a new job intest.yml
for linting calledlint
, which contains the following step:# Also add checkout and setup go steps here, like in previous tasks - name: Verify formatting run: | no_unformatted_files="$(gofmt -l $(git ls-files '*.go') | wc -l)" exit "$no_unformatted_files"
-
Push the code and verify that the workflow runs two jobs successfully.
Workflows can be triggered in many different ways and can be grouped into four type of events:
- Repository related events
- External events
- Scheduled triggering
- Manual triggering
Repository related events are the most common and are triggered when something happens in the repository. External events and scheduled triggers are not covered in this workshop, but it is nice to know that it is possible. Some example triggers:
on: push # Triggers when a push is made to the repository
on: pull_request # Triggers when a pull request is opened or changed
on: workflow_dispatch # Triggers when a user manually requests a workflow to run
Some events have filters that can be applied to limit when the workflow should run. For example, the push
-event has a branches
-filter that can be used limit the workflow to only run if it is on a specific branch (or branches)
on:
push:
branches:
- main
- 'releases/**' # Wildcard can be used to limit to a specific set of branches
- Rewrite the docker build workflow
build.yml
to only be done on main and rewrite the build and lint workflowtest.yml
to only run on PR changes. Push the changes to main-branch and observe that only the build-workflow is executed. - Create a new feature branch, add a new commit with a dummy change (to any file) and finally create a PR to main. Verify that the
test.yml
workflow is run on the feature branch. Merge the PR and verify that thebuild.yml
-workflow is only run on the main-branch. - Update the
test.yml
workflow and add the event for triggering the workflow manually. Make sure to push the change to main-branch. - Go to the GitHub Actions page of the workflow and verify that the workflow can be run manually. A
Run workflow
button should appear to enable you to manually trigger the workflow.
Note
In order for the Run workflow
-button to appear the workflow must exist on the default branch, typically the main
-branch
Reusable workflows makes it possible to avoid duplication and reuse common workflow-functionality. They can be shared within a single repository or by the whole organization.
To pass information to a shared workflow you should either use the vars
-context or pass information directly to the workflow. The variables for the vars
-context can be found here.
Reusable workflows use the workflow_call
-trigger. A simple reusable workflow that accepts a config value as input look like this:
on:
workflow_call:
inputs:
config-value:
required: true
type: string
To call a reusable workflow in the same repository:
jobs:
call-workflow-passing-data:
uses: ./.github/workflows/my-reusable-workflow.yml
with:
config-value: 'Some value'
- Create a reusable workflow that runs the test-job specified in
test.yml
and modifytest.yml
to use the reusable workflow for running the tests - Create a reusable workflow for the the code in
build.yml
and use a input-parameter to determine if the image should be pushed or not.
Note
A limitation of reusable workflows is that you have to run it as a single job, without the possibility to run additional steps before or after in the same environment. If you want to create reusable code that runs in the same environment, you can create a custom action which we will look at later in the workshop.
For the purposes of this workshop, we'll not actually deploy to any environment, but create a couple of GitHub environments to demonstrate how deployments would work. You can use environments to track deploys to a given environment, and set environment-specific variables required to deploy your application.
Example of a deployment job to an environment:
jobs:
deployment:
runs-on: ubuntu-latest
environment: production # This environment applies to all steps in this job
steps:
- name: deploy
-
Navigate to Settings > Environments and create two new environments:
test
andproduction
. For each environment set a unique environment variable,WORKSHOP_ENV_VARIABLE
. -
Create a new workflow in
.github/workflows/deploy.yml
. This workflow should trigger onworkflow_dispatch
, and take three inputs:environment
of typeenvironment
, and the stringsimageName
anddigest
. It should have a single job,deploy
, and here it should just "fake" the deploy by printing theimageName
anddigest
. All inputs should be required setrequired
totrue
. The job should run in context of the input environment and print the environment variable${{ vars.WORKSHOP_ENV_VARIABLE }}
configured for the environment. -
Push the new workflow (to main-branch), and verify that you get a dropdown to select the environment when you trigger it, and that the value of
WORKSHOP_ENV_VARIABLE
is printed for the chosen environment.
Jobs can depend on each other. We'll now create a workflow that builds, then deploys the Docker image to test and production, in that order.
-
Modify the
deploy.yml
to make it reusable by adding aworkflow_call
trigger. It should have the same inputs as theworkflow_dispatch
trigger. -
Modify your reusable build action to propagate outputs. You'll need to add an
id: build-push
to the step that builds the image. Then, you can add anoutputs
object property to the job and the workflow.To get the correct outputs from the
docker/build-push-action
action, you should usefromJson(jobs.build.outputs.metadata)['image.name']
andjobs.build.outputsdigest
outputs from the build-push action asimageName
anddigest
respectively. You can read more about thefromJson
expression in the documentation.Take a look at the documentation for a complete example of outputs for a reusable workflow.
-
Expand your (non-reusable) build workflow with a couple of more jobs:
deploy-test
anddeploy-production
. These jobs should reuse thedeploy.yml
workflow, useimageName
anddigest
outputs from thebuild
job and use correct environments. You have to specifyneeds
for the deploy jobs, take a look at theneeds
context and corresponding example. -
Push the workflow, and verify that the jobs run correctly, printing the correct docker image specification and environment variable. The
deploy-test
job should also finish before theproduction-test
job starts.
Many teams want require code reviews, avoid accidental changes, run tests or ensure that the formatting is correct on all new code before merging. These restrictions can be done using branch protection rules.
You can find branch protections rules by going to Settings > Branches (requires repository administrator privileges). Let's create a branch protection rule for the main
branch:
-
Set
main
as the branch name pattern. -
Set the setting "Require a pull request before merging", and untick the "Require Approvals" sub-setting.
-
Set the setting "Require status checks to pass before merging", and make sure that both the jobs for linting and testing are selected.
-
Set the "Do not allow bypassing the above settings" setting to disallow administrator overrides, and finally click "Create".
-
Create a change (e.g. text change in the README). Try pushing the change from your local computer directly to
main
and verify that it gets rejected (if you're using the GitHub UI, you will be forced to create a branch). -
Create a change on a separate branch, push it and create a PR. Verify that you cannot merge it until the status checks have passed.
-
Optionally, turn off the "Require a pull request before merging" and/or "Do not allow bypassing the above settings" settings before you continue, to simplify the rest of the workshop. Read through the list of settings once more and research options you want to know more about in the documentation.
Tip
Branch protection rules will disallow force pushes for everyone, including administrators, by default, but this can be turned on again in the settings.
- Create reusable composite actions for build, use as part of jobs on PR and main pushes
- Caching docker image build
- Gated prod deploy
- Don't trigger build on non-source code changes
- Only deploy prod on main branch
- Environment secrets