Adopt commit-based container tags (#232)

## Summary
- Replace git-tag-triggered container builds with path-based triggers on main and workflow_dispatch
- Image tags now encode upstream app version + commit SHA (`vX.Y.Z-<sha>`) for full traceability
- Replace `container-tag-and-release` task with `container-build-and-release` (dispatches workflows via Forgejo API)
- Update dagger `publish()` to accept `commit_sha` parameter
- Update all docs and references to the new workflow

## Deployment and Testing
- [ ] Merge to main
- [ ] `mise run container-build-and-release <name>` for each container to populate new-format tags
- [ ] Verify tags in registry via `mise run container-list`
- [ ] Existing images untouched — old tags remain available

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/232
This commit is contained in:
Erich Blume 2026-02-20 22:56:20 -08:00
commit ffa8727660
13 changed files with 363 additions and 258 deletions

View file

@ -18,11 +18,15 @@ class BlumeopsCi:
src: dagger.Directory,
container_name: str,
version: str,
commit_sha: str,
registry: str = "registry.ops.eblu.me",
) -> str:
"""Build and push to registry. Returns the image ref."""
"""Build and push to registry. Returns the image ref.
Tag format: {version}-{commit_sha} (e.g. v1.0.0-abc1234)
"""
ctr = self.build(src, container_name)
ref = f"{registry}/blumeops/{container_name}:{version}"
ref = f"{registry}/blumeops/{container_name}:{version}-{commit_sha}"
return await ctr.publish(ref)
@function

View file

@ -1,82 +1,138 @@
# Nix container build workflow
# Triggers on tags matching: <container>-v<version>
# Builds from containers/<container>/default.nix if it exists, skips otherwise
# Pushes to Zot registry via skopeo with -nix image tag suffix
#
# Examples:
# nettest-v1.0.0 -> builds containers/nettest/default.nix, pushes :v1.0.0-nix
# devpi-v2.1.0 -> skips (no default.nix)
# Triggers on pushes to main that modify containers/*, or via manual dispatch.
# Detects which containers changed, builds from default.nix, and pushes via
# skopeo with commit-SHA-based tags: vX.Y.Z-<sha>-nix
name: Build Container (Nix)
on:
push:
tags:
- '*-v[0-9]*'
branches: [main]
paths: ['containers/**']
workflow_dispatch:
inputs:
container:
description: 'Container name (directory under containers/)'
required: true
type: string
ref:
description: 'Commit SHA to build (defaults to current HEAD)'
required: false
type: string
jobs:
build:
detect:
runs-on: nix-container-builder
outputs:
containers: ${{ steps.list.outputs.containers }}
steps:
- name: Parse tag
id: parse
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed containers
id: list
run: |
TAG="${GITHUB_REF_NAME}"
echo "Tag: $TAG"
# Extract container name (everything before -v)
# e.g., "nettest-v1.0.0" -> "nettest", "my-app-v2.0.0" -> "my-app"
CONTAINER="${TAG%-v[0-9]*}"
VERSION="${TAG#"${CONTAINER}"-}"
echo "container=$CONTAINER" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Container: $CONTAINER"
echo "Version: $VERSION"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CONTAINERS='["${{ inputs.container }}"]'
else
CONTAINERS=$(git diff --name-only HEAD~1 HEAD -- containers/ \
| cut -d/ -f2 | sort -u \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
fi
echo "containers=$CONTAINERS" >> "$GITHUB_OUTPUT"
echo "Containers to build: $CONTAINERS"
build:
needs: detect
if: needs.detect.outputs.containers != '[]'
runs-on: nix-container-builder
strategy:
matrix:
container: ${{ fromJson(needs.detect.outputs.containers) }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check if nix container exists
- name: Check for default.nix
id: check
run: |
CONTAINER="${{ steps.parse.outputs.container }}"
CONTEXT="containers/$CONTAINER"
if [ -f "$CONTEXT/default.nix" ]; then
echo "Found $CONTEXT/default.nix"
if [ -f "containers/${{ matrix.container }}/default.nix" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "No default.nix found at $CONTEXT/default.nix — skipping"
echo "No default.nix for ${{ matrix.container }} — skipping"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Extract version and SHA
if: steps.check.outputs.exists == 'true'
id: meta
run: |
CONTAINER="${{ matrix.container }}"
NIX_FILE="containers/$CONTAINER/default.nix"
# Try extracting version = "..." from the nix file (e.g. ntfy)
VERSION=$(grep -m1 '^\s*version\s*=\s*"' "$NIX_FILE" \
| sed 's/.*"\(.*\)".*/\1/' || true)
# Fall back to CONTAINER_APP_VERSION from Dockerfile (e.g. nettest)
if [ -z "$VERSION" ] && [ -f "containers/$CONTAINER/Dockerfile" ]; then
VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \
"containers/$CONTAINER/Dockerfile" \
| sed 's/^ARG CONTAINER_APP_VERSION=//')
fi
# Last resort: dagger call nix-version for nixpkgs packages (e.g. authentik)
if [ -z "$VERSION" ]; then
VERSION=$(dagger call nix-version --package="$CONTAINER")
fi
if [ -z "$VERSION" ]; then
echo "Error: Could not determine version for $CONTAINER"
exit 1
fi
REF="${{ inputs.ref }}"
if [ -z "$REF" ]; then
REF="${GITHUB_SHA}"
fi
SHORT_SHA=$(echo "$REF" | head -c 7)
# Ensure version starts with 'v'
case "$VERSION" in
v*) ;;
*) VERSION="v${VERSION}" ;;
esac
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION, SHA: $SHORT_SHA"
- name: Resolve nixpkgs
if: steps.check.outputs.exists == 'true'
id: nixpkgs
run: |
# Resolve nixpkgs from the flake registry for <nixpkgs> lookup
NIXPKGS_PATH=$(nix flake metadata nixpkgs --json | jq -r '.path')
echo "Resolved nixpkgs: $NIXPKGS_PATH"
echo "path=$NIXPKGS_PATH" >> "$GITHUB_OUTPUT"
- name: Build with nix
if: steps.check.outputs.exists == 'true'
id: build
env:
NIX_PATH: "nixpkgs=${{ steps.nixpkgs.outputs.path }}"
run: |
CONTAINER="${{ steps.parse.outputs.container }}"
echo "Building containers/$CONTAINER/default.nix"
echo "Building containers/${{ matrix.container }}/default.nix"
echo "NIX_PATH=$NIX_PATH"
nix-build "containers/$CONTAINER/default.nix" -o result
nix-build "containers/${{ matrix.container }}/default.nix" -o result
echo "Build complete: $(readlink result)"
- name: Push to registry
if: steps.check.outputs.exists == 'true'
run: |
CONTAINER="${{ steps.parse.outputs.container }}"
VERSION="${{ steps.parse.outputs.version }}"
IMAGE="registry.ops.eblu.me/blumeops/$CONTAINER:$VERSION-nix"
CONTAINER="${{ matrix.container }}"
VERSION="${{ steps.meta.outputs.version }}"
SHORT_SHA="${{ steps.meta.outputs.sha }}"
IMAGE="registry.ops.eblu.me/blumeops/$CONTAINER:${VERSION}-${SHORT_SHA}-nix"
echo "Pushing to $IMAGE"
skopeo copy \

View file

@ -1,77 +1,105 @@
# Generic container build workflow
# Triggers on tags matching: <container>-v<version>
# Builds from containers/<container>/Dockerfile if it exists
#
# Uses Dagger to build and push images to the Zot registry.
#
# Examples:
# nettest-v1.0.0 -> builds containers/nettest/
# devpi-v2.1.0 -> builds containers/devpi/
# foo-v1.0.0 -> skips if containers/foo/ doesn't exist
# Dockerfile container build workflow
# Triggers on pushes to main that modify containers/*, or via manual dispatch.
# Detects which containers changed, extracts version from CONTAINER_APP_VERSION,
# and publishes with commit-SHA-based tags: vX.Y.Z-<sha>
name: Build Container
on:
push:
tags:
- '*-v[0-9]*'
branches: [main]
paths: ['containers/**']
workflow_dispatch:
inputs:
container:
description: 'Container name (directory under containers/)'
required: true
type: string
ref:
description: 'Commit SHA to build (defaults to current HEAD)'
required: false
type: string
jobs:
build:
detect:
runs-on: k8s
outputs:
containers: ${{ steps.list.outputs.containers }}
steps:
- name: Parse tag
id: parse
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed containers
id: list
run: |
TAG="${GITHUB_REF_NAME}"
echo "Tag: $TAG"
# Extract container name (everything before -v)
# e.g., "nettest-v1.0.0" -> "nettest", "my-app-v2.0.0" -> "my-app"
CONTAINER="${TAG%-v[0-9]*}"
VERSION="${TAG#"${CONTAINER}"-}"
echo "container=$CONTAINER" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Container: $CONTAINER"
echo "Version: $VERSION"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CONTAINERS='["${{ inputs.container }}"]'
else
# Diff against parent commit to find changed container dirs
CONTAINERS=$(git diff --name-only HEAD~1 HEAD -- containers/ \
| cut -d/ -f2 | sort -u \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
fi
echo "containers=$CONTAINERS" >> "$GITHUB_OUTPUT"
echo "Containers to build: $CONTAINERS"
build:
needs: detect
if: needs.detect.outputs.containers != '[]'
runs-on: k8s
strategy:
matrix:
container: ${{ fromJson(needs.detect.outputs.containers) }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check if container exists
- name: Check for Dockerfile
id: check
run: |
CONTAINER="${{ steps.parse.outputs.container }}"
CONTEXT="containers/$CONTAINER"
if [ -f "$CONTEXT/Dockerfile" ]; then
echo "Found $CONTEXT/Dockerfile"
if [ -f "containers/${{ matrix.container }}/Dockerfile" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "No Dockerfile found at $CONTEXT/Dockerfile"
echo "No Dockerfile for ${{ matrix.container }} — skipping"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Skip if container not found
if: steps.check.outputs.exists != 'true'
- name: Extract version and SHA
if: steps.check.outputs.exists == 'true'
id: meta
run: |
echo "========================================"
echo "Container not found: ${{ steps.parse.outputs.container }}"
echo "========================================"
echo ""
echo "Tag '${{ github.ref_name }}' does not match any container in containers/"
echo ""
echo "Available containers:"
find containers -maxdepth 1 -mindepth 1 -type d -exec basename {} \; 2>/dev/null | sort | while read -r name; do
echo " - $name"
done || echo " (none)"
echo ""
echo "Skipping build."
VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \
"containers/${{ matrix.container }}/Dockerfile" \
| sed 's/^ARG CONTAINER_APP_VERSION=//')
if [ -z "$VERSION" ]; then
echo "Error: No CONTAINER_APP_VERSION found in Dockerfile"
exit 1
fi
# Use dispatch input ref if provided, otherwise current commit
REF="${{ inputs.ref }}"
if [ -z "$REF" ]; then
REF="${GITHUB_SHA}"
fi
SHORT_SHA=$(echo "$REF" | head -c 7)
# Ensure version starts with 'v'
case "$VERSION" in
v*) ;; # already has v prefix
*) VERSION="v${VERSION}" ;;
esac
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION, SHA: $SHORT_SHA"
- name: Publish
if: steps.check.outputs.exists == 'true'
run: |
dagger call publish \
--src=. \
--container-name=${{ steps.parse.outputs.container }} \
--version=${{ steps.parse.outputs.version }}
--container-name=${{ matrix.container }} \
--version=${{ steps.meta.outputs.version }} \
--commit-sha=${{ steps.meta.outputs.sha }}

View file

@ -0,0 +1 @@
Container builds now trigger automatically on merge to main (path-based) and use commit-SHA-based image tags (`vX.Y.Z-<sha>`) for full traceability. The `container-tag-and-release` task is replaced by `container-build-and-release` which dispatches workflows via the Forgejo API. Added pre-commit hook to keep container versions in sync with `service-versions.yaml`.

View file

@ -71,16 +71,14 @@ When an attempt fails and you discover prerequisites, the branch must be cleaned
The branch between attempts should contain only documentation. Code returns when prerequisites are satisfied and the next attempt succeeds.
### Build artifacts and tags
### Build artifacts
Mikado resets apply to branch code, not build artifacts. Container images in the registry and git tags created by `container-tag-and-release` are independent of branch lifecycle:
Mikado resets apply to branch code, not build artifacts. Container images in the registry are independent of branch lifecycle:
- **Git tags** point to commit SHAs, not branches — they survive branch deletion and force-pushes.
- **Registry images** are build outputs cached in zot — a wrong image is overwritten by the next release.
- **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned, bump the version, and try again.
- **If a build fails in CI**, no image is pushed. Delete the git tag (`git tag -d <tag> && git push --delete origin <tag>`) and fix the nix/dockerfile before re-releasing.
Tag freely during leaf node work. The build IS the verification step — deferring it creates a chicken-and-egg where the card can't be marked complete without a built image.
- **Registry images** are build outputs cached in zot — tagged with commit SHAs, so each build is unique and traceable.
- **Automatic builds** trigger when container changes merge to main. Use `mise run container-build-and-release` for manual dispatch.
- **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned and try again.
- **If a build fails in CI**, no image is pushed. Fix the nix/dockerfile and re-merge or re-dispatch.
## Card Conventions

View file

@ -18,7 +18,7 @@ Discovered while attempting [[deploy-authentik]]: the deployment references `reg
1. Verify `containers/authentik/default.nix` builds — locally via Dagger (`dagger call build-nix --src=. --container-name=authentik`) or on ringtail (the CI nix builder runs there)
2. The `ak` entrypoint needs bash (included via `bashInteractive`) and orchestrates both `server` and `worker` subcommands
3. Tag and release: `mise run container-tag-and-release authentik v1.0.0`
3. Trigger build: `mise run container-build-and-release authentik`
4. Verify the `-nix` tagged image appears in the registry
## What We Learned

View file

@ -52,20 +52,23 @@ nix-build containers/<name>/default.nix -o result
## 3. Release
Once the image builds cleanly, create a tagged release:
Container builds trigger automatically when changes to `containers/<name>/` are merged to `main`. Both workflows fire and each skips if the relevant build file is absent.
To trigger a manual build (e.g. from a branch or to rebuild at a specific commit):
```bash
mise run container-tag-and-release <name> v1.0.0
mise run container-build-and-release <name>
mise run container-build-and-release <name> --ref <commit-sha>
```
Use `--dry-run` to preview without creating tags.
This creates a single git tag `<name>-v1.0.0` and pushes it. Both Forgejo workflows trigger on the tag — each checks for its build file and skips if not present:
Use `--dry-run` to preview without dispatching.
| Build file | Workflow | Runner | Registry tag |
|------------|----------|--------|--------------|
| `Dockerfile` | `build-container.yaml` | `k8s` (indri) | `:v1.0.0` |
| `default.nix` | `build-container-nix.yaml` | `nix-container-builder` ([[ringtail]]) | `:v1.0.0-nix` |
| `Dockerfile` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-<sha>` |
| `default.nix` | `build-container-nix.yaml` | `nix-container-builder` ([[ringtail]]) | `:vX.Y.Z-<sha>-nix` |
The version (`X.Y.Z`) is extracted from `ARG CONTAINER_APP_VERSION=` in the Dockerfile or `version = "..."` in `default.nix`. The SHA is the short (7-char) commit hash.
Check available images and tags with:
@ -78,7 +81,7 @@ mise run container-list
Change the image reference in `argocd/manifests/<service>/deployment.yaml`:
```yaml
image: registry.ops.eblu.me/blumeops/<name>:v1.0.0
image: registry.ops.eblu.me/blumeops/<name>:vX.Y.Z-abc1234
```
Then deploy per [[deploy-k8s-service]].

View file

@ -1,7 +1,6 @@
---
title: Adopt Commit-Based Container Tags
modified: 2026-02-20
status: active
requires:
- add-container-version-sync-check
tags:
@ -29,9 +28,9 @@ Currently, container builds trigger on git tags matching `<container>-vX.Y.Z`. T
### Triggers
1. **Merged changes to main** — any push to `main` that modifies files under `containers/<name>/` triggers builds for that container
2. **Manual workflow dispatch** — for ad-hoc builds (e.g., testing on a branch). Accepts two inputs:
2. **Manual workflow dispatch** — for ad-hoc builds. Accepts two inputs:
- `container` (required) — which container to build
- `ref` (optional, string) — the source commit SHA to build, defaulting to `HEAD` of `main`
- `ref` (optional, string) — the source commit SHA to build, defaulting to `GITHUB_SHA`
Both the Dockerfile and Nix workflows fire for each trigger, each bailing out if the container lacks the relevant build file (same as today).
@ -40,56 +39,43 @@ Both the Dockerfile and Nix workflows fire for each trigger, each bailing out if
Each container's version is extracted at build time from existing declarations — no separate VERSION file:
- **Dockerfile builds**: parsed from `ARG CONTAINER_APP_VERSION=<value>` in the Dockerfile
- **Nix builds**: extracted via `dagger call nix-version` or `nix eval`
- **Nix builds**: extracted from `version = "..."` in `default.nix`, or `CONTAINER_APP_VERSION` from the Dockerfile, or `dagger call nix-version` for nixpkgs packages
The [[add-container-version-sync-check]] pre-commit check ensures these declarations stay in sync with `service-versions.yaml`. See [[pin-container-versions]] for the work to ensure every container has a parseable version.
### Image Tag Format
The registry image tag encodes the app version and the exact source commit:
| Scenario | Dockerfile tag | Nix tag |
|----------|---------------|---------|
| Main branch build | `vX.Y.Z-<sha>` and `vX.Y.Z-<sha>-main` | `vX.Y.Z-<sha>-nix` and `vX.Y.Z-<sha>-main-nix` |
| Manual dispatch | `vX.Y.Z-<sha>` | `vX.Y.Z-<sha>-nix` |
| Build type | Tag format | Example |
|------------|-----------|---------|
| Dockerfile | `vX.Y.Z-<sha>` | `v2.2.17-abc1234` |
| Nix | `vX.Y.Z-<sha>-nix` | `v2.17.0-abc1234-nix` |
Where:
- `X.Y.Z` is the version of the most relevant bundled app (e.g., miniflux `2.2.5`, navidrome `0.53.3`)
- `<sha>` is the short commit SHA of the source tree used for the build
The `-main` tag indicates a build from the merged main branch, suitable for production deployment. Non-main builds (manual dispatch) omit this suffix.
- `X.Y.Z` is the version of the most relevant bundled app (e.g., miniflux `2.2.17`, navidrome `0.60.3`)
- `<sha>` is the 7-char short commit SHA of the source tree used for the build
### What This Replaces
- The `container-tag-and-release` mise task is **renamed and repurposed** to `container-build-and-release` — it triggers a manual workflow dispatch instead of creating git tags. It sends the current `HEAD` SHA so that it works from any branch, not just main
- The `container-tag-and-release` mise task is **replaced** by `container-build-and-release` — it triggers a manual workflow dispatch instead of creating git tags
- Git tags of the form `<container>-vX.Y.Z` are no longer used to trigger builds
- The `container-list` mise task should be updated to display the new tag format
- The `container-list` mise task displays the new tag format
## Key Files
| File | Change |
|------|--------|
| `.forgejo/workflows/build-container.yaml` | Replace tag trigger with path + dispatch triggers; compute version and SHA; push multiple tags |
| `.forgejo/workflows/build-container.yaml` | Replace tag trigger with path + dispatch triggers; compute version and SHA |
| `.forgejo/workflows/build-container-nix.yaml` | Same trigger changes; add `-nix` suffix to new tag format |
| `.dagger/src/blumeops_ci/main.py` | Accept SHA parameter; publish with new tag format |
| `mise-tasks/container-build-and-release` | Rename from `container-tag-and-release`; trigger workflow dispatch with current HEAD SHA |
| `mise-tasks/container-list` | Update tag display for new format |
| `docs/how-to/deployment/build-container-image.md` | Document new workflow |
| `mise-tasks/container-build-and-release` | New task replacing `container-tag-and-release`; triggers workflow dispatch |
| `mise-tasks/container-list` | Updated tag display for new format |
| `docs/how-to/deployment/build-container-image.md` | Updated documentation |
## Interaction With Other Prereqs
- **[[enforce-tag-immutability]]** — Commit SHA tags are inherently unique, reducing the scope of immutability enforcement to the `-main` rolling tag (if that is treated as mutable/latest) or eliminating it entirely if `-main` tags are also SHA-qualified (as proposed above)
- **[[enforce-tag-immutability]]** — Commit SHA tags are inherently unique, reducing the scope of immutability enforcement
- **[[wire-ci-registry-auth]]** — Auth changes apply regardless of tagging scheme; no conflict
## Verification
- [ ] Push to main modifying `containers/nettest/` triggers both Docker and Nix builds
- [ ] Resulting image tags match `vX.Y.Z-<sha>` and `vX.Y.Z-<sha>-main` format
- [ ] Nix tags have `-nix` suffix
- [ ] Manual workflow dispatch builds with correct tags (no `-main` suffix)
- [ ] `mise run container-list` shows new tag format
- [ ] Existing deployments referencing old tags still work (images not deleted)
## Related
- [[harden-zot-registry]] — Parent goal

View file

@ -91,7 +91,7 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
| `pr-comments` | Check unresolved PR comments during review |
| `blumeops-tasks` | Find pending tasks from Todoist |
| `container-list` | View available container images and tags |
| `container-tag-and-release` | Release a new container image version |
| `container-build-and-release` | Trigger container build workflows |
| `dns-preview` | Preview DNS changes before applying |
| `dns-up` | Apply DNS changes via Pulumi |
| `tailnet-preview` | Preview Tailscale ACL changes |

View file

@ -0,0 +1,142 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["typer>=0.15.0", "httpx>=0.28.0"]
# ///
#MISE description="Trigger container build workflows via Forgejo API"
#USAGE arg "<container>" help="Container name (directory under containers/)"
#USAGE flag "--ref <ref>" help="Commit SHA to build (defaults to current HEAD)"
#USAGE flag "--dry-run" help="Show what would be done without triggering"
"""Trigger container build workflows via Forgejo API dispatch.
Dispatches both Build Container and Build Container (Nix) workflows.
Each workflow checks for its build file and skips if not present.
"""
import subprocess
import sys
from pathlib import Path
import httpx
import typer
REGISTRY = "registry.ops.eblu.me"
FORGE_URL = "https://forge.ops.eblu.me"
FORGE_API = f"{FORGE_URL}/api/v1"
REPO = "eblume/blumeops"
FORGE_ACTIONS = f"{FORGE_URL}/{REPO}/actions"
WORKFLOWS = [
"build-container.yaml",
"build-container-nix.yaml",
]
app = typer.Typer(add_completion=False)
def git(*args: str) -> str:
result = subprocess.run(
["git", *args], capture_output=True, text=True, check=True
)
return result.stdout.strip()
def get_forge_token() -> str:
result = subprocess.run(
["op", "read", "op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def list_containers() -> None:
typer.echo("Available containers:")
for d in sorted(Path("containers").iterdir()):
if not d.is_dir():
continue
types = []
if (d / "Dockerfile").exists():
types.append("dockerfile")
if (d / "default.nix").exists():
types.append("nix")
if types:
typer.echo(f" - {d.name} ({', '.join(types)})")
@app.command()
def main(
container: str = typer.Argument(help="Container name (directory under containers/)"),
ref: str = typer.Option("", "--ref", help="Commit SHA to build (defaults to current HEAD)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without triggering"),
) -> None:
"""Trigger container build workflows via Forgejo API dispatch."""
container_dir = Path("containers") / container
has_dockerfile = (container_dir / "Dockerfile").exists()
has_nix = (container_dir / "default.nix").exists()
if not has_dockerfile and not has_nix:
typer.echo(f"Error: No Dockerfile or default.nix found in '{container_dir}'")
typer.echo()
list_containers()
raise typer.Exit(1)
if not ref:
ref = git("rev-parse", "HEAD")
short_sha = ref[:7]
image = f"blumeops/{container}"
# Show expected builds
builds = []
if has_dockerfile:
builds.append(f" dockerfile -> {REGISTRY}/{image}:v<version>-{short_sha}")
if has_nix:
builds.append(f" nix -> {REGISTRY}/{image}:v<version>-{short_sha}-nix")
if dry_run:
typer.echo("[dry-run mode]")
typer.echo(f"Container: {container}")
typer.echo(f"Commit: {ref} ({short_sha})")
typer.echo(f"Expected builds:")
for b in builds:
typer.echo(b)
typer.echo()
if dry_run:
typer.echo("[dry-run] Would dispatch workflows:")
for wf in WORKFLOWS:
typer.echo(f" - {wf}")
typer.echo()
typer.echo(f"Monitor builds at: {FORGE_ACTIONS}")
return
token = get_forge_token()
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
for wf in WORKFLOWS:
url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{wf}/dispatches"
payload = {
"ref": "main",
"inputs": {
"container": container,
"ref": ref,
},
}
resp = httpx.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code == 204:
typer.echo(f"Dispatched {wf}")
else:
typer.echo(f"Error dispatching {wf}: {resp.status_code} {resp.text}")
raise typer.Exit(1)
typer.echo()
typer.echo(f"Monitor builds at: {FORGE_ACTIONS}")
if __name__ == "__main__":
app()

View file

@ -52,10 +52,11 @@ for dir in "$CONTAINER_DIR"/*/; do
done
echo "---"
echo "To release a new version:"
echo " mise run container-tag-and-release <container> <version>"
echo "To trigger a build:"
echo " mise run container-build-and-release <container>"
echo ""
echo "One tag triggers all applicable workflows (dockerfile and/or nix)."
echo "Dispatches both Dockerfile and Nix workflows (each skips if build file absent)."
echo "Tags: vX.Y.Z-<sha> (Dockerfile), vX.Y.Z-<sha>-nix (Nix)"
echo ""
echo "Example:"
echo " mise run container-tag-and-release nettest v1.0.0"
echo " mise run container-build-and-release nettest"

View file

@ -1,114 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["typer>=0.15.0"]
# ///
#MISE description="Release a container image by creating a git tag"
#USAGE arg "<container>" help="Container name (directory under containers/)"
#USAGE arg "<version>" help="Version in vX.Y.Z format"
#USAGE flag "--dry-run" help="Show what would be done without creating tags"
"""Release a container image by creating a git tag that triggers CI builds.
One tag triggers all applicable workflows:
- Dockerfile present -> Build Container workflow -> :v<version>
- default.nix present -> Build Container (Nix) workflow -> :v<version>-nix
"""
import re
import subprocess
import sys
from pathlib import Path
import typer
REGISTRY = "registry.ops.eblu.me"
FORGE_ACTIONS = "https://forge.ops.eblu.me/eblume/blumeops/actions"
app = typer.Typer(add_completion=False)
def git(*args: str) -> str:
result = subprocess.run(
["git", *args], capture_output=True, text=True, check=True
)
return result.stdout.strip()
def git_tag_exists(tag: str) -> bool:
result = subprocess.run(
["git", "rev-parse", tag], capture_output=True, text=True
)
return result.returncode == 0
def list_containers() -> None:
typer.echo("Available containers:")
for d in sorted(Path("containers").iterdir()):
if not d.is_dir():
continue
types = []
if (d / "Dockerfile").exists():
types.append("dockerfile")
if (d / "default.nix").exists():
types.append("nix")
if types:
typer.echo(f" - {d.name} ({', '.join(types)})")
@app.command()
def main(
container: str = typer.Argument(help="Container name (directory under containers/)"),
version: str = typer.Argument(help="Version in vX.Y.Z format"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without creating tags"),
) -> None:
"""Release a container image by creating a git tag that triggers CI builds."""
if not re.match(r"^v\d+\.\d+\.\d+$", version):
typer.echo("Error: Version must be in format vX.Y.Z (e.g. v1.0.0)")
raise typer.Exit(1)
container_dir = Path("containers") / container
has_dockerfile = (container_dir / "Dockerfile").exists()
has_nix = (container_dir / "default.nix").exists()
if not has_dockerfile and not has_nix:
typer.echo(f"Error: No Dockerfile or default.nix found in '{container_dir}'")
typer.echo()
list_containers()
raise typer.Exit(1)
image = f"blumeops/{container}"
tag = f"{container}-{version}"
# Show what workflows will trigger
builds = []
if has_dockerfile:
builds.append(f" dockerfile -> {REGISTRY}/{image}:{version}")
if has_nix:
builds.append(f" nix -> {REGISTRY}/{image}:{version}-nix")
if dry_run:
typer.echo("[dry-run mode]")
typer.echo(f"Container: {container}")
typer.echo(f"Tag: {tag}")
typer.echo(f"Builds:")
for b in builds:
typer.echo(b)
typer.echo()
if git_tag_exists(tag):
typer.echo(f"Error: Tag '{tag}' already exists")
raise typer.Exit(1)
if dry_run:
typer.echo(f"[dry-run] Would create and push tag: {tag}")
else:
git("tag", tag)
git("push", "origin", tag)
typer.echo(f"Tag '{tag}' created and pushed")
typer.echo()
typer.echo(f"Monitor builds at: {FORGE_ACTIONS}")
if __name__ == "__main__":
app()

View file

@ -168,7 +168,7 @@ def main(
checklist_parts += [
"\n[bold]Custom Container (hybrid):[/bold]\n",
"• Check base image for updates\n",
"• Rebuild container if needed: mise run container-tag-and-release\n",
"• Rebuild container if needed: mise run container-build-and-release\n",
"• Update ArgoCD manifest with new image tag\n",
]
elif svc_type == "argocd":