Merge pull request 'Build-scan-promote pipeline: explicit trivy-scan, podman-promote, deploy-prod actions' (#2) from build-scan-promote into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
peet committed 2026-08-29 13:51:21 +02:00
commit 01bc638759
5 files changed
+271 -75

No files matched your search

+99 -21
View File
@@ -2,54 +2,132 @@
Reusable Gitea Actions composite workflows for noctrl infrastructure automation.
## The standard pipeline (since v11)
Build once, scan explicitly, promote — the registry is the hand-off between
workflows. A push to the default branch builds each architecture exactly once
and pushes immutable, never-deployed candidate tags (`sha-<commit>[-<arch>]`);
an explicit Trivy job scans the candidate. A release rebuilds nothing: it
re-scans the candidate and retags it server-side.
`build.yml` (push to default branch):
```
validate (pre-commit) → build-amd64 ─┐
build-arm64 ─┤→ manifest (multi-arch only) → scan
```
`release.yml` (tag `v*`):
```
scan (candidate of the tagged commit) → promote (sha-<commit> → vX)
```
The scan in `release.yml` is the enforcing gate: a release tag can only come
into existence behind a scan that passed at promotion time. Candidates carrying
CVEs may exist in the registry — nothing consumes `sha-*` tags, so they are
inert. Scan and promote jobs always run on `linux,build,amd64` (never on the
slow arm runners; vulnerability results are package-based and arch-independent).
Before v11, `podman-build-publish@v10` briefly embedded the Trivy gate itself;
v11 moved it into the explicit `trivy-scan` action.
## Available Actions
### Podman Build And Publish
Builds and optionally pushes OCI container images to `registry.noctrl.eu` using Podman with isolated storage context.
Since `v10`, the built image is scanned with Trivy before the push and the action fails on fixable HIGH/CRITICAL vulnerabilities (tune with `scan-severity`, opt out with `scan: "false"`). Requires the `trivy` binary on the runner (present in `noctrl/gitea-runner` since `v9`).
Builds and optionally pushes OCI container images to `registry.noctrl.eu` using
Podman with isolated storage context. Build and candidate-push are deliberately
one action: each invocation initializes its own podman storage, so a split
build/push pair would have to re-pull what was just built.
**Location:** [`./podman-build-publish`](./podman-build-publish)
**Use in workflows:**
```yaml
- uses: https://gitea.noctrl.eu/noctrl/actions/podman-build-publish@v1
- uses: https://gitea.noctrl.eu/public/actions/podman-build-publish@v11
with:
image-name: noctrl/myapp
tags: |
latest
sha-${{ github.sha }}
sha-${{ github.sha }}-amd64
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
```
See [podman-build-publish README](./podman-build-publish/README.md) for full documentation.
### Podman Manifest Publish
Creates and pushes OCI multi-arch manifest tags to `registry.noctrl.eu` using
Podman with isolated storage context.
Creates and pushes OCI multi-arch manifest tags to `registry.noctrl.eu` —
used to assemble the per-arch candidates into the multi-arch candidate.
**Location:** [`./podman-manifest-publish`](./podman-manifest-publish)
**Use in workflows:**
```yaml
- uses: https://gitea.noctrl.eu/noctrl/actions/podman-manifest-publish@v1
- uses: https://gitea.noctrl.eu/public/actions/podman-manifest-publish@v11
with:
image-name: noctrl/myapp
manifest-tag: v1.2.3
manifest-tag: sha-${{ github.sha }}
source-tags: |
v1.2.3-tmp-123-amd64
v1.2.3-tmp-123-arm64
sha-${{ github.sha }}-amd64
sha-${{ github.sha }}-arm64
```
See [podman-manifest-publish README](./podman-manifest-publish/README.md) for full documentation.
### Trivy Scan
Scans an image ref in `registry.noctrl.eu` and fails on fixable HIGH/CRITICAL
vulnerabilities (severities configurable). Requires `trivy` on the runner
(`noctrl/gitea-runner` >= v9). Pin the job to `linux,build,amd64`.
**Location:** [`./trivy-scan`](./trivy-scan)
```yaml
- uses: https://gitea.noctrl.eu/public/actions/trivy-scan@v11
with:
image-name: noctrl/myapp
tag: sha-${{ github.sha }}
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
```
### Podman Promote
Retags an existing image (or multi-arch manifest list) server-side via
`skopeo copy --all` — no rebuild, no transfer, fails if the source tag does not
exist. Requires `skopeo` on the runner (`noctrl/gitea-runner` >= v10).
**Location:** [`./podman-promote`](./podman-promote)
```yaml
- uses: https://gitea.noctrl.eu/public/actions/podman-promote@v11
with:
image-name: noctrl/myapp
source-tag: sha-${{ github.sha }}
target-tag: ${{ github.ref_name }}
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
```
### Deploy Prod
Shared deploy body for the `*.podman` infrastructure repos: installs the SSH
deploy key, validates the mounted deploy-target env file, and pipes the deploy
runner's `deploy-prod.sh` to the production host over SSH. Runs on that host's
dedicated deploy runner (`gitea-runner-deploy` image).
**Location:** [`./deploy-prod`](./deploy-prod)
```yaml
- uses: https://gitea.noctrl.eu/public/actions/deploy-prod@v11
with:
deploy-target-file: /run/noctrl/prod-deploy-target.env
ssh-key-b64: ${{ secrets.PROD_DEPLOY_SSH_KEY }}
target-sha: ${{ github.sha }}
```
## Usage
Reference actions by absolute URL in your workflow:
```yaml
- uses: https://gitea.noctrl.eu/noctrl/actions/<action-name>@<ref>
- uses: https://gitea.noctrl.eu/public/actions/<action-name>@<ref>
```
**Ref Options:**
@@ -63,6 +141,7 @@ Actions reference caller repository secrets. Ensure your repository has required
- **REGISTRY_USERNAME** — registry.noctrl.eu authentication username
- **REGISTRY_PASSWORD** — registry.noctrl.eu authentication password
- **PROD_DEPLOY_SSH_KEY** — base64-encoded SSH deploy key (`*.podman` repos only)
Set these in repository **Settings** → **Secrets**.
@@ -71,6 +150,5 @@ Set these in repository **Settings** → **Secrets**.
When adding new actions:
1. Create action directory: `./action-name/`
2. Include `action.yaml` (composite action definition)
3. Include `README.md` with inputs, outputs, and usage example
4. Tag stable versions: `git tag v1`, `git tag v2`, etc.
5. Update this root README
3. Tag stable versions: `git tag v1`, `git tag v2`, etc.
4. Update this root README
+66
View File
@@ -0,0 +1,66 @@
name: Deploy Prod
description: >
Deploy a commit of a *.podman infrastructure repo on its production host.
Runs on that host's dedicated deploy runner (gitea-runner-deploy image):
installs the deployment SSH key, reads the mounted deploy-target env file,
and pipes the runner image's deploy-prod.sh to the host over SSH. Host key
verification uses the known_hosts file mounted into the deploy runner.
inputs:
deploy-target-file:
description: >
Path of the mounted deploy target env file, for example
/run/noctrl/prod-deploy-target.env. Must define REPO_DIR, PROD_HOST,
PROD_USER, PROD_PORT.
required: true
ssh-key-b64:
description: Base64-encoded private SSH deploy key (PROD_DEPLOY_SSH_KEY secret).
required: true
target-sha:
description: Commit SHA to deploy (normally github.sha).
required: true
runs:
using: composite
steps:
- id: install-key
shell: bash
env:
SSH_KEY_B64: ${{ inputs.ssh-key-b64 }}
run: |
set -euo pipefail
: "${SSH_KEY_B64:?missing ssh-key-b64 input (PROD_DEPLOY_SSH_KEY secret)}"
install -d -m 0700 ~/.ssh
printf '%s' "${SSH_KEY_B64}" | base64 -d > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
- id: deploy
shell: bash
env:
TARGET_SHA: ${{ inputs.target-sha }}
DEPLOY_TARGET_FILE: ${{ inputs.deploy-target-file }}
run: |
set -euo pipefail
if [[ ! -f "${DEPLOY_TARGET_FILE}" ]]; then
echo "missing mounted deploy target file: ${DEPLOY_TARGET_FILE}" >&2
exit 1
fi
set -a
. "${DEPLOY_TARGET_FILE}"
set +a
: "${REPO_DIR:?missing REPO_DIR in ${DEPLOY_TARGET_FILE}}"
: "${PROD_HOST:?missing PROD_HOST in ${DEPLOY_TARGET_FILE}}"
: "${PROD_USER:?missing PROD_USER in ${DEPLOY_TARGET_FILE}}"
: "${PROD_PORT:?missing PROD_PORT in ${DEPLOY_TARGET_FILE}}"
ssh \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-i ~/.ssh/id_ed25519 \
-p "${PROD_PORT}" "${PROD_USER}@${PROD_HOST}" \
"REPO_DIR='${REPO_DIR}' bash -s -- '${TARGET_SHA}'" \
< /usr/local/bin/deploy-prod.sh
-54
View File
@@ -37,20 +37,6 @@ inputs:
Set to "false" for build-only verification workflows.
required: false
default: "true"
scan:
description: |
Whether to scan the built image with Trivy before pushing. The step
fails — blocking the push — when vulnerabilities at or above the
configured severities with an available fix are found (unfixed CVEs
are ignored: nothing actionable, and Debian/Alpine bases always carry
some). Requires the trivy binary on the runner. Set to "false" only
with a documented reason.
required: false
default: "true"
scan-severity:
description: Comma-separated Trivy severities that fail the scan.
required: false
default: HIGH,CRITICAL
runs:
using: composite
@@ -155,46 +141,6 @@ runs:
build_cmd+=("${{ inputs.context }}")
"${build_cmd[@]}"
- if: ${{ inputs.scan != 'false' }}
id: scan
shell: bash
run: |
set -euo pipefail
: "${PODMAN_STORAGE_DRIVER:=vfs}"
: "${PODMAN_STORAGE_OPT:=}"
podman_args=(
--root "${RUNNER_TEMP}/podman-root"
--runroot "${RUNNER_TEMP}/podman-runroot"
--storage-driver "${PODMAN_STORAGE_DRIVER}"
)
if [[ -n "${PODMAN_STORAGE_OPT}" ]]; then
podman_args+=(--storage-opt "${PODMAN_STORAGE_OPT}")
fi
image_base="registry.noctrl.eu/${{ inputs.image-name }}"
first_tag="$(printf '%s\n' "${{ inputs.tags }}" | tr ', ' '\n\n' | sed '/^$/d' | head -n1)"
# Trivy cannot read podman's storage under a custom --root, so scan
# an exported archive instead. Must be docker-archive: trivy's
# --input does not understand oci-archive tarballs (verified).
archive="${RUNNER_TEMP}/trivy-scan-image.tar"
rm -f "${archive}"
podman "${podman_args[@]}" save --format docker-archive -o "${archive}" "${image_base}:${first_tag}"
echo "Scanning ${image_base}:${first_tag} (fail on: ${{ inputs.scan-severity }}, fixed only)"
trivy image \
--input "${archive}" \
--scanners vuln \
--severity "${{ inputs.scan-severity }}" \
--ignore-unfixed \
--exit-code 1 \
--no-progress
rm -f "${archive}"
- if: ${{ inputs.push != 'false' }}
id: push
shell: bash
+52
View File
@@ -0,0 +1,52 @@
name: Podman Promote
description: >
Promote an existing image in registry.noctrl.eu by retagging it server-side
(skopeo copy within the same registry — blobs are reused, no rebuild, no
transfer). Works for single-arch images and multi-arch manifest lists alike.
Fails if the source tag does not exist; it never falls back to building.
inputs:
image-name:
description: Repository/image name path, for example noctrl/gitea-runner
required: true
source-tag:
description: Existing tag to promote, for example sha-abc123
required: true
target-tag:
description: Tag to publish, for example v1.2.3
required: true
registry-username:
description: Registry username for login.
required: true
registry-password:
description: Registry password for login.
required: true
runs:
using: composite
steps:
- id: promote
shell: bash
run: |
set -euo pipefail
export REGISTRY_AUTH_FILE="${RUNNER_TEMP}/promote-auth.json"
if ! command -v skopeo >/dev/null 2>&1; then
echo "ERROR: skopeo not found on this runner (needs noctrl/gitea-runner >= v10)" >&2
exit 1
fi
image_base="registry.noctrl.eu/${{ inputs.image-name }}"
source_ref="docker://${image_base}:${{ inputs.source-tag }}"
target_ref="docker://${image_base}:${{ inputs.target-tag }}"
cleanup() { rm -f "${REGISTRY_AUTH_FILE}"; }
trap cleanup EXIT
echo "${{ inputs.registry-password }}" | skopeo login registry.noctrl.eu \
--username "${{ inputs.registry-username }}" --password-stdin
echo "Promoting ${source_ref} -> ${target_ref}"
# --all: copy every manifest of a multi-arch list, or the single image.
skopeo copy --all "${source_ref}" "${target_ref}"
+54
View File
@@ -0,0 +1,54 @@
name: Trivy Scan
description: >
Scan an image in registry.noctrl.eu for vulnerabilities and fail on findings.
Pulls the image directly from the registry — no podman storage involved.
Pin the calling job to an amd64 runner (never arm); vulnerability results are
package-based and arch-independent, so scanning a multi-arch ref once
(trivy picks the host platform's variant) covers all variants.
inputs:
image-name:
description: Repository/image name path, for example noctrl/gitea-runner
required: true
tag:
description: Tag to scan, for example sha-abc123 or sha-abc123-amd64
required: true
severity:
description: Comma-separated Trivy severities that fail the scan.
required: false
default: HIGH,CRITICAL
registry-username:
description: Registry username for pulling the image.
required: true
registry-password:
description: Registry password for pulling the image.
required: true
runs:
using: composite
steps:
- id: scan
shell: bash
env:
TRIVY_USERNAME: ${{ inputs.registry-username }}
TRIVY_PASSWORD: ${{ inputs.registry-password }}
run: |
set -euo pipefail
if ! command -v trivy >/dev/null 2>&1; then
echo "ERROR: trivy not found on this runner (needs noctrl/gitea-runner >= v9)" >&2
exit 1
fi
image_ref="registry.noctrl.eu/${{ inputs.image-name }}:${{ inputs.tag }}"
# --ignore-unfixed: unfixed base-image CVEs are unactionable and would
# perma-block builds; the gate is on vulnerabilities with a fix.
echo "Scanning ${image_ref} (fail on: ${{ inputs.severity }}, fixed only)"
trivy image \
--scanners vuln \
--severity "${{ inputs.severity }}" \
--ignore-unfixed \
--exit-code 1 \
--no-progress \
"${image_ref}"