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/workflows directory.
  • In this chapter, we will explain each section of the /.github/workflows/ci.yaml step-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: write
  • runs-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@v4

The 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.3 Step 3: Extract Docker Metadata (Tags and Labels)

- name: Extract metadata (tags, labels) for Docker
  id: meta
  uses: docker/metadata-action@v5.5.1
  with:
    images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

This step uses the docker/metadata-action to extract metadata (such as tags and labels) from the repository and build context. The extracted metadata will be used later when tagging the Docker image.

  • id: meta: This assigns an identifier meta to the step, making its outputs accessible in later steps.
  • images: This specifies the base name for the Docker image, which is constructed from the container registry and the repository name.

The output from this step is crucial for correctly tagging and labeling the Docker image in the next step.

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:

  1. Build a Docker image using the repository’s Dockerfile.
  2. 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 meta step (${{ steps.meta.outputs.tags }}).
  • labels: Additional labels for the image, also provided by the meta step (${{ 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:

  1. Checks out the repository.
  2. Logs into the container registry.
  3. Extracts metadata to tag and label the image.
  4. 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).

WarningPin an immutable version tag, not :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.1

Then push it with this command:

git push --tags
ImportantNever move a released git tag — cut a new version instead

Once 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 commit

The 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 version0.0.4 — and repoint the step at it. It costs one tag and saves an afternoon of debugging code that isn’t running.

TipWhile iterating, pin a commit SHA rather than re-using a tag

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).

ImportantThe image name must be lowercase

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.

ImportantThe image must live in a trusted registry namespace

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/.

WarningForks have GitHub Actions disabled by default

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_NAME explicitly (not ${{ github.repository }});
  • authenticate with a Personal Access Token (PAT) that has write:packages on the target org, stored as a repository secret — the built-in GITHUB_TOKEN cannot push to another org;
  • set the login username to the owner of that PAT — a mismatch between username and the PAT owner produces denied: denied at 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’s container does 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.

ImportantThe built-in 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.

NoteThe image build still succeeds

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.)