Reusable Forgejo workflows every repo's CI uses: build-image (with Trivy scan), scan-image, digger (not deployed)
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-02 00:17:50 +00:00
.forgejo/workflows Update dependency python to 3.15 2026-11-01 23:17:52 +00:00
.gitignore Scotland Yard: log only status on Paperclip errors, drop pycache 2026-10-29 09:53:49 +01:00
README.md Remove the Scotland Yard handoff 2026-10-29 22:30:23 +01:00

Pipeline Templates

Reusable Forgejo Actions workflows. CI builds and scans images; Argo CD deploys them from each repo's .argo/ directory, so there are no deploy workflows here.

Usage

Reference workflows by full URL. Forgejo resolves bare owner/repo against its default actions host, not this instance.

jobs:
  image:
    uses: https://code.levior.io/levior/pipeline-templates/.forgejo/workflows/build-image.yml@main
    with:
      images: ./Dockerfile|api|.
    secrets: inherit

The team's REGISTRY_USERNAME and REGISTRY_PASSWORD org secrets are picked up through secrets: inherit, and images go to reg.levior.io/<owner>/<name>.

Both workflows run on the docker runner label and need a runner with access to a Docker daemon.

build-image.yml

One build job per image. Pushes <registry>/<owner>/<name>:<commit sha> and, by default, :<branch>. Layer cache lives in the registry as <image>:buildcache. Every image carries the OCI labels org.opencontainers.image.source (the repo URL) and org.opencontainers.image.revision (the commit), so an image leads back to its code.

The image name is the full name under the owner, so a repo with several images names them itself, e.g. api and api-frontend.

Input Default Description
images required One image per line as dockerfile|name|context
registry reg.levior.io Registry hostname
registry_username REGISTRY_USERNAME secret Registry username
push true Push the image. false only builds it and refreshes the :buildcache layer cache, e.g. on pull requests, so the build after the merge reuses the PR's layers
push_branch_tag true Also push the sanitized branch name as a tag
artifact '' Artifact from an earlier job of the same run to download into the checkout before the build, for images that package something built outside Docker (upload it with https://code.forgejo.org/forgejo/upload-artifact@v4)
artifact_path . Where the artifact goes, relative to the checkout
build_args '' Newline-separated KEY=VALUE build args
secret_files '' Newline-separated id=path buildx secret mounts, path relative to the repo
generated_secrets '' Newline-separated buildx secret ids that get a random value per run
npm_scope '' Scope served by a private npm registry. When set, the build gets an .npmrc as the npmrc secret
npm_registry Forgejo npm registry for levior Registry serving npm_scope
require_postgres false Start a postgres service and pass its connection string as the DATABASE_URL build secret over host networking
postgres_version 17
postgres_database / postgres_username / postgres_password app
source_url https://code.levior.io Public forge address for the org.opencontainers.image.source label; the runner's github.server_url is Forgejo's in-cluster address
scan false Scan the image with Trivy before it is pushed, from the same build. Findings at or above scan_severity fail the job and nothing is pushed. Use this instead of a separate scan-image.yml job, which builds the image a second time
scan_severity CRITICAL,HIGH Severities that fail the scan
scan_ignore_unfixed true Ignore vulnerabilities without an upstream fix
scan_ignorefile .trivyignore.yaml Trivy ignore file in the repo, used when it exists. Give every entry an expired_at: after that date it stops applying and the scan fails again, so the exception gets looked at
Secret Description
registry_password Registry password. Defaults to the REGISTRY_PASSWORD secret
build_secrets Newline-separated id=value buildx secrets
npm_token Token for npm_registry. Defaults to the run token
composer_token Token that can read private repositories for Composer packages pulled from git. Merged into the composer_auth secret

Output tag is the full commit SHA. Argo CD's build environment exposes the same value as ARGOCD_APP_REVISION, which is why the tag is not shortened.

scan-image.yml

Trivy scan, one job per image, same images format. Logs in to the same registry with the same credentials, builds the image locally without pushing and fails on findings at or above severity.

Prefer build-image.yml with scan: true: it scans the image it is about to push, so the image is built once instead of twice. This workflow stays for repos that scan without building with build-image.yml.

Input Default Description
images required One image per line as dockerfile|name|context
registry reg.levior.io Registry hostname
registry_username REGISTRY_USERNAME secret Registry username
severity CRITICAL,HIGH Severities that fail the job
ignore_unfixed true Ignore findings without an upstream fix
build_args '' Build args for the local build

digger.yml

Runs Digger in backendless mode on pull requests: tofu plan when a PR is opened or updated, tofu apply on a digger apply comment, and digger unlock when the PR closes. Results land as PR comments and digger/plan / digger/apply commit statuses. The repo needs a digger.yml at its root; PR-level locks are off since there is no DynamoDB or GCS here.

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
  issue_comment:
    types: [created]

# One apply at a time, queued behind any other apply in the repo (use the
# same group as a workflow that applies on push to main). Other runs are
# grouped per pull request.
concurrency:
  group: ${{ (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger apply')) && 'tofu-apply' || format('digger-{0}', github.event.pull_request.number || github.event.issue.number) }}
  cancel-in-progress: false

jobs:
  digger:
    if: github.event_name != 'issue_comment' || startsWith(github.event.comment.body, 'digger')
    runs-on: ubuntu-latest
    uses: https://code.levior.io/levior/pipeline-templates/.forgejo/workflows/digger.yml@main
    secrets: inherit

Forgejo needs runs-on on the calling job, unlike GitHub Actions; without it the run stays in "waiting".

digger apply refuses a branch that is behind its base: Digger applies the branch as it is, so a branch that misses commits from main would undo them (levior/infra #63 undid #62 that way). The run comments the missing commits and fails; update the branch, wait for the new plan and comment digger apply again. Forgejo only honours concurrency at the workflow level of the calling workflow, not inside this one, so the caller sets it (above): without it two applies started together could both pass the check before either merges. It also holds an apply lock: once the required checks are green, before Digger applies, the PR gets the label digger:applied (created if missing), and a digger apply on any other PR is refused, with a comment, while an open PR carries it. The label comes off when the PR closes; closed without merging, the run comments that main must be applied again, because the applied changes are still live (levior/infra #73 undid the half-applied #72 before this lock existed). A stuck label can be removed by hand. Every repo secret named TF_VAR_* or AWS_* is exported to the OpenTofu run. Forgejo stores secret names in upper case, so a TF_VAR_ secret is exported with its suffix lower-cased (TF_VAR_STATE_PASSPHRASE becomes TF_VAR_state_passphrase), which matches snake_case variable names. A PVE_SSH_KEY secret, when present, is loaded into an ssh-agent for providers that need SSH. The run token (GITHUB_TOKEN) is what Digger uses to comment and set statuses. A DIGGER_FORGEJO_TOKEN secret, when present, replaces it: Digger then comments, sets statuses and, with auto_merge: true in digger.yml, merges as that user, which is what a protected branch with a merge whitelist needs. The token needs write:repository and write:issue.

Input Default Description
digger_version latest Release tag of levior/digger to run
opentofu_version 1.12.6 OpenTofu version to install

Exceptions with a deadline

A finding that can't be fixed yet (a dependency only a framework BOM can move) goes into the repo's .trivyignore.yaml with a statement and an expired_at, at most two weeks out:

vulnerabilities:
  - id: CVE-2024-57699
    statement: json-smart 2.5.0 via kafka-oauth, pinned by the Quarkus BOM; drop when the BOM has 2.5.2
    expired_at: 2026-11-11

On that date the entry stops applying, the next build fails on the finding, and someone has to look again.

CI

ci.yml checks every workflow here with forgejo-runner validate (Forgejo's own schema, at the runners' version), fails on a secrets: block under workflow_call (the schema allows it, Forgejo doesn't). ci / validate (pull_request) is required on main.