Eliminate double towncrier run in release workflow (#199)

## Summary

- Added a new `build_quartz` Dagger function that builds the Quartz site from a pre-processed source tree (no towncrier)
- Reordered the release workflow so towncrier runs **once** on the runner, then passes the updated working tree to `build-quartz`
- `build_docs` and `build_changelog` are preserved for standalone use — `build_docs` now delegates to `build_quartz` internally

## Motivation

Previously towncrier ran twice per release: once inside a Dagger container (via `build_docs` → `build_changelog`) and once on the runner to capture CHANGELOG.md changes for the git commit. This was wasteful and fragile — if towncrier behavior changed, the two runs could produce different results.

## Test plan

- [ ] Review diff to confirm workflow step ordering is correct
- [ ] Trigger a release and confirm towncrier runs only once
- [ ] Verify the docs tarball contains the updated CHANGELOG.md
- [ ] `dagger call build-quartz --src=. --version=vX.Y.Z` should work standalone

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/199
This commit is contained in:
Erich Blume 2026-02-16 21:24:34 -08:00
commit 779b7d6709
5 changed files with 21 additions and 45 deletions

View file

@ -23,36 +23,15 @@ class BlumeopsCi:
ref = f"{registry}/blumeops/{container_name}:{version}"
return await ctr.publish(ref)
@function
async def build_changelog(
self, src: dagger.Directory, version: str
) -> dagger.Directory:
"""Run towncrier to build changelog, return modified source tree."""
return await (
dag.container()
.from_("python:3.12-slim")
.with_env_variable("TZ", "America/Los_Angeles")
# git is required because towncrier stages CHANGELOG.md via git add
.with_exec(["apt-get", "update", "-qq"])
.with_exec(["apt-get", "install", "-y", "-qq", "git"])
.with_exec(["pip", "install", "towncrier"])
.with_directory("/workspace", src)
.with_workdir("/workspace")
.with_exec(["git", "init"])
.with_exec(["towncrier", "build", "--version", version, "--yes"])
.directory("/workspace")
)
@function
async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File:
"""Build changelog then Quartz site. Returns docs tarball."""
updated_src = await self.build_changelog(src, version)
"""Build Quartz docs site. Returns docs tarball."""
return await (
dag.container()
.from_("node:22-slim")
.with_exec(["apt-get", "update", "-qq"])
.with_exec(["apt-get", "install", "-y", "-qq", "git"])
.with_directory("/workspace", updated_src)
.with_directory("/workspace", src)
.with_workdir("/workspace")
.with_exec(
[

View file

@ -108,30 +108,14 @@ jobs:
with:
fetch-depth: 0
- name: Build docs
run: |
VERSION="${{ steps.version.outputs.version }}"
TARBALL="docs-${VERSION}.tar.gz"
echo "Building docs via Dagger..."
# build-docs calls build_changelog internally (towncrier runs inside
# the Dagger container). The host working tree is not modified — only
# the tarball is exported. Towncrier runs a second time on the runner
# in the next step so that CHANGELOG.md and fragment deletion are
# captured in the git commit.
dagger call build-docs --src=. --version="$VERSION" \
export --path="./$TARBALL"
echo "Build complete!"
ls -lh "$TARBALL"
- name: Build changelog
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Run towncrier on the runner (not in Dagger) so that CHANGELOG.md
# updates and fragment deletions appear in the working tree for the
# git commit step. This is intentionally a second towncrier run —
# the first happened inside the Dagger build-docs container above.
# Run towncrier on the runner so that CHANGELOG.md updates and
# fragment deletions appear in the working tree for both the Quartz
# build (next step) and the git commit step.
# Check if there are any changelog fragments
FRAGMENTS=$(find docs/changelog.d -name "*.md" -not -name ".gitkeep" 2>/dev/null | wc -l)
@ -157,6 +141,19 @@ jobs:
echo "" > /tmp/release_notes.md
fi
- name: Build docs
run: |
VERSION="${{ steps.version.outputs.version }}"
TARBALL="docs-${VERSION}.tar.gz"
echo "Building docs via Dagger..."
# Towncrier already ran on the runner above, so the working tree
# has an up-to-date CHANGELOG.md. build-docs now only runs the
# Quartz static site build (no towncrier).
dagger call build-docs --src=. --version="$VERSION" \
export --path="./$TARBALL"
echo "Build complete!"
ls -lh "$TARBALL"
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1 @@
Eliminate double towncrier run in release workflow — changelog is now built once on the runner, then the pre-processed source tree is passed to a new `build_quartz` Dagger function for the Quartz site build only.

View file

@ -26,7 +26,7 @@ Direct link: https://forge.ops.eblu.me/eblume/blumeops/actions?workflow=build-bl
The `build-blumeops` workflow (`.forgejo/workflows/build-blumeops.yaml`):
1. **Resolves version** — Uses input or auto-increments from latest release
2. **Builds changelog**Calls `dagger call build-changelog` (towncrier in a container)
2. **Builds changelog**Runs towncrier on the runner to update `CHANGELOG.md`
3. **Builds docs** — Calls `dagger call build-docs` (Quartz build in a container)
4. **Creates release** — Uploads `docs-<version>.tar.gz` to Forgejo releases
5. **Updates deployment** — Edits `argocd/manifests/docs/deployment.yaml` with new URL

View file

@ -27,8 +27,7 @@ Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts wi
|----------|-----------|-------------|
| `build` | `(src, container_name) → Container` | Build a container from `containers/<name>/Dockerfile` |
| `publish` | `(src, container_name, version, registry?) → str` | Build and push to registry (default: `registry.ops.eblu.me`) |
| `build_changelog` | `(src, version) → Directory` | Run towncrier to collect changelog fragments |
| `build_docs` | `(src, version) → File` | Build changelog then Quartz site, return docs tarball |
| `build_docs` | `(src, version) → File` | Build Quartz docs site, return docs tarball |
## CLI Examples