11 Continuous Integration Workflow
11.1 General idea
- Everytime you push a new commit, a GitHub Workflow will be triggered.
- This workflow will build and push the Docker image containing the operator code and its execution environment .
- This workflow is specified in a YAML file you can find in the
/.github/workflowsdirectory. - In this chapter, we will explain each section of the
/.github/workflows/ci.yamlstep-by-step to clarify its purpose and functionality.
11.2 1. Workflow Name and Trigger
name: CI Workflow
on:
push:
branches: ['main']The workflow is named CI Workflow, which is mainly for identification purposes within the GitHub Actions dashboard. The workflow is triggered by a push event, but only for the main branch. This means that every time code is pushed to the main branch of the repository, this workflow will automatically run.
11.3 2. Environment Variables
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}Two environment variables are defined here:
REGISTRY: This is set to
ghcr.io, which stands for GitHub Container Registry. It is the domain where the Docker images will be stored.IMAGE_NAME: This variable is dynamically set to the name of the GitHub repository using
github.repository. The${{ github.repository }}syntax is GitHub’s way of accessing context values, such as the repository name.
The image name will be used later when tagging the Docker image.
11.4 3. Job Definition: build-and-push-image
The core of the workflow is defined within a single job called build-and-push-image.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: writeruns-on: ubuntu-latest: The job will run on the latest available version of Ubuntu. This provides a virtual machine with all the necessary tools to run Docker and interact with GitHub.
permissions: This section grants specific permissions to the job:
contents: read: Allows reading the repository’s contents (code, files).packages: write: Allows publishing Docker images to GitHub Packages.attestations: write: Grants access to manage attestations, which are metadata related to builds.id-token: write: Allows the job to issue ID tokens for authentication, commonly used with OpenID Connect for secure token exchanges.
11.5 4. Steps in the Workflow
This job consists of several steps, each performing a crucial task in the workflow.
11.5.1 Step 1: Checkout Repository
- name: Checkout repository
uses: actions/checkout@v4The first step uses the actions/checkout action, version v4. This action checks out the repository code so that it can be accessed in the subsequent steps. Without this step, the job wouldn’t be able to access the files in the repository.
11.5.2 Step 2: Log in to the Container Registry
- name: Log in to the Container registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}In this step, the workflow logs into GitHub’s Container Registry (ghcr.io) using the docker/login-action. This action allows you to authenticate to a container registry, enabling the workflow to push Docker images.
- registry: Set to the value of
${{ env.REGISTRY }}(ghcr.io). - username: Set to
${{ github.actor }}, which represents the username of the person or bot that triggered the workflow. - password: Set to
${{ secrets.GITHUB_TOKEN }}, a built-in secret provided by GitHub to authenticate the workflow. This token allows the workflow to access the repository’s packages and perform the necessary actions.
11.5.4 Step 4: Build and Push Docker Image
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6.7.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}In this final step, the docker/build-push-action is used to:
- Build a Docker image using the repository’s
Dockerfile. - Push the built image to the GitHub Container Registry.
Key parameters in this step:
- context: Set to
.(the current directory). This specifies the build context, meaning the files in the repository’s root directory will be used for the Docker build. - push: Set to
true. This ensures the Docker image is automatically pushed to the registry after it is built. - tags: The tags for the image, provided by the output of the
metastep (${{ steps.meta.outputs.tags }}). - labels: Additional labels for the image, also provided by the
metastep (${{ steps.meta.outputs.labels }}).
11.5.5 Optional: Personal Access Token
# A PAT is needed for this action, GITHUB_TOKEN cannot get relevant permission
# secrets: |
# github_pat=${{ secrets.GH_PAT }}This is an optional comment explaining that if higher-level permissions are required, a Personal Access Token (PAT) should be used instead of the default GITHUB_TOKEN. This would be defined as a secret (GH_PAT).
11.6 Conclusion
This CI pipeline builds and pushes Docker images to GitHub Container Registry every time changes are made to the main branch. The workflow:
- Checks out the repository.
- Logs into the container registry.
- Extracts metadata to tag and label the image.
- Builds and pushes the Docker image to the registry.
By using GitHub Actions and Docker together, this workflow automates the process of containerizing code and storing it in a centralized registry, enabling future installation from Tercen.
12 Release Workflow
Once you are satisfied with your operator, you can release your operator by following the steps described below.
12.1 1. Edit the operator.json file
You should first edit the container field of the operator.json file so that it matched the version number you would like to push (here, 0.0.1 for example).
:main
Always point the container field at an immutable, versioned image tag (e.g. :0.0.1) — never a mutable tag like :main or :master. A mutable tag is overwritten by every new build, so a workflow step configured for “version 0.0.1” will silently pull whatever was built most recently. This means rolling back to an older version does not actually roll back the image, and a step that worked yesterday can break today with no change on your side. The release workflow below rewrites container to the pushed tag for exactly this reason.
12.2 2. Push your changes and tag the repository
After pushing your latest commit, you can add the same version number as a tag from the command line as follows:
git tag 0.0.1Then push it with this command:
git push --tagsOnce a tag has been released and installed anywhere, treat it as permanent. Do not delete and re-push it to point at a newer commit:
# Don't do this on a tag that has already been installed
git tag -d 0.0.3
git push --delete origin 0.0.3
git tag 0.0.3 && git push --tags # same name, different commitThe tag name will move on GitHub, but already-installed instances keep running the old code. Tercen resolves a version to a commit at first install and stores the operator under a directory named after that resolved commit (<org>-<repo>-<short-sha>). On subsequent runs it checks whether that directory already exists and, if so, skips the download entirely — so the tag is never re-resolved and the new commit is never fetched. The step keeps running the original code, with no error and nothing in the logs to suggest a stale version.
The fix is to cut a new version — 0.0.4 — and repoint the step at it. It costs one tag and saves an afternoon of debugging code that isn’t running.
Operators are installed by downloading https://github.com/<org>/<repo>/zipball/<version>, and <version> may be a branch, a tag, or a full commit SHA. During rapid back-and-forth testing, pinning the SHA gives every build a distinct identity, so you never have to wonder whether you are looking at the new code:
https://github.com/myorg/my_operator@2c23a2d1f0b...
Each distinct SHA installs into its own directory, so the stale-directory problem above cannot occur. Switch back to a proper version tag before handing the operator to users.
12.3 3. Wait and check the results of the release workflow
The same way a workflow is triggered after each commit, tagging a repository will trigger another workflow, release.yaml. This workflow that will build and push the Docker image, tag it with the run the unit tests. Once the workflow is run, you can verify it has been successful. If that is the case, you are now ready to install your operator in a Library.
13 GitHub Container Registry (ghcr): requirements & common pitfalls
The template workflow above logs in with ${{ github.actor }} + the built-in GITHUB_TOKEN and derives IMAGE_NAME from ${{ github.repository }}. That is enough for the simple case — a lowercase-named repository publishing to its own organization. The points below cover the requirements and the pitfalls that most often leave a developer with “no image” (and therefore an operator that silently fails to install).
Docker/OCI image names must be entirely lowercase. If your repository name contains uppercase letters (e.g. My_Operator or uka_operator_BN_UKAdb), the default IMAGE_NAME: ${{ github.repository }} produces an invalid reference and the build fails with repository name must be lowercase. Use a lowercase repository name, or set IMAGE_NAME explicitly to a lowercase value.
Managed Tercen instances only pull operator images from trusted container namespaces — commonly ghcr.io/tercen/ and ghcr.io/pamgene/. An image published anywhere else is rejected at install time with tercen.forbidden.untrusted.docker. Make sure the container field in your operator.json points to an image under a trusted namespace (ask your admin which are configured).
This means the image organization can differ from the repository organization — e.g. a repository under pamgene/ typically publishes its image to ghcr.io/tercen/.
When you fork an operator repository, GitHub disables Actions on the fork, so CI never runs and no image is ever built. Installation then fails because the image does not exist. Enable workflows first (fork’s Actions tab → “I understand my workflows, enable them”) before pushing.
13.1 Publishing to a different organization than your repository
If you need to publish to a different org’s namespace than the repository lives in (e.g. a pamgene/ repo publishing to ghcr.io/tercen/), the template defaults are not enough. You must:
- set
IMAGE_NAMEexplicitly (not${{ github.repository }}); - authenticate with a Personal Access Token (PAT) that has
write:packageson the target org, stored as a repository secret — the built-inGITHUB_TOKENcannot push to another org; - set the login
usernameto the owner of that PAT — a mismatch betweenusernameand the PAT owner producesdenied: deniedat login/push. A stale or malformed secret value produces the same error.
env:
REGISTRY: ghcr.io
IMAGE_NAME: tercen/my_operator # explicit, lowercase, trusted namespace
# ...
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: your-pat-owner # must match the PAT owner
password: ${{ secrets.GHCR_PAT }}13.2 A few more ghcr facts worth knowing
- The image is pulled when the operator is installed. If the tag referenced by
operator.json’scontainerdoes not exist in ghcr, installation fails and no operator is registered. Confirm CI actually pushed the tag you reference (e.g.:main, or:0.0.1). - Images persist independently of git. Renaming or deleting a branch/tag does not rename or delete the corresponding image in ghcr — you must rebuild to produce a new tag. Old tags keep working until overwritten.
- Private images are fine. Tercen can pull private operator images; the image does not need to be public to be installable.
14 Private repos: release-workflow install step & tokens
The release.yml optional Install operator step runs tercenctl operator install, which makes the Tercen server download your repo’s zipball from GitHub. For a private repo this needs a token that can read the repo’s contents — and the built-in GITHUB_TOKEN is not enough.
GITHUB_TOKEN can’t pull a private repo’s zipball
The install step commonly passes -e GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}. That token is scoped to the Actions runner; when the external Tercen server uses it to fetch https://github.com/<org>/<repo>/zipball/<ref> for a private repo, GitHub returns 404 (it returns 404 rather than 403 for an under-privileged token on a private repo):
task.git.operator.download.failed -- 404 : Request failed :
https://github.com/<org>/<repo>/zipball/<sha>
This is not a “trusted repos” problem — that would surface as tercen.forbidden.untrusted.git, and only requires the repo be under a trusted org. It is purely a token-permission issue.
Fix: pass a Personal Access Token with repo scope (classic) — or a fine-grained token with Contents: Read — instead of the built-in token:
- name: Install operator
run: |
docker run --rm \
-e TERCEN_USERNAME=${{ secrets.TERCEN_TEST_OPERATOR_USERNAME }} \
-e TERCEN_PASSWORD=${{ secrets.TERCEN_TEST_OPERATOR_PASSWORD }} \
-e TERCEN_URI=${{ secrets.TERCEN_TEST_OPERATOR_URI }} \
-e GITHUB_TOKEN=${{ secrets.GH_PAT }} \ # PAT with repo/contents:read, NOT the built-in GITHUB_TOKEN
tercen/tercenctl:release operator install --rm --tag ${GITHUB_SHA} \
--repo ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}Public repos don’t need this — the zipball is fetchable without auth.
This only affects the optional CI install step. The build-and-push of the versioned image runs before it and succeeds regardless — so the operator image exists and can be installed via the library even if this step is red. If you don’t need the CI install/test-on-release, you can remove the Install step entirely and rely on the library add. (Reminder from earlier: the repo name must end in _operator for the library add to install it.)