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

@ -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":