workflow: build, tag & publish the agent image on manual dispatch #7

Open
opened 2026-11-14 15:21:28 +00:00 by hermes_forgeron · 1 comment
Collaborator

Goal

A Forgejo Actions workflow in this repository — .forgejo/workflows/image.yml — triggered manually (workflow_dispatch, no inputs) that builds the agent image, tags it, pushes it to the instance registry, and reports the new tag. The chain build → tag → push must involve zero LLM calls.

Why

Today this is manual (docs/setup.md §3): docker build, docker push, a hand-made tag (20261013-1), then a hand edit of the agent-image default in .forgejo/workflows/agent.yml. Automating the chain removes all of that. Merging stays with @mlvtito and deployment keeps its human gate: the workflow opens a small PR bumping the default tag, and merging that PR is his decision.

Tag governance (decision)

  • Primary tag, immutable per build: YYYYMMDD-N — UTC build date + the workflow run number (e.g. 20261114-3). Chronological, unique, cannot collide with the legacy hand-made tags.
  • latest pushed alongside, always pointing at the newest build — human convenience only. Nothing in this repository may reference latest for the agent image: the agent-image default stays pinned to immutable tags. (Mirrors the style of the tsuru base image, which is referenced as latest.)
  • Git tag on the repository, same name as the image tag (20261114-N). The 1:1 string mapping makes the image ↔ workflow-version link greppable — no mapping table to maintain, no prefix to remember. Created after the image push succeeds (order: build → push image → create git tag → bump PR), so a git tag can never point at a commit whose image was not published.
  • The git tag is annotated, and its message closes the loop: git tag -a $TAG -m "agent image $TAG — commit $GITHUB_SHA, registry digest $DIGEST" (digest captured from the pushed image). One lookup links image ↔ code ↔ digest.
  • latest never becomes a git tag — a mutable name has no meaning in git; only the immutable tag is created.
  • Known caveat, stated once so nobody treats it as a contract: the tag is a pointer, not a reproducibility guarantee — the image is not byte-reproducible from the commit (npm install floats). The base image stays on tsuru:latest BY DECISION of @mlvtito: tsuru is his own tool, evolves slowly, and only needs to bring the right tool versions — do not open digest-pinning issues for it.
  • Semantics note: the tagged commit still contains the previous agent-image default (the bump PR is created after the tag). That is correct: the git tag certifies what was built, the bump PR certifies what is deployed. Two different facts, deliberately kept apart.
  • Tag push rights: push the git tag with bot + BOT_TOKEN (same identity as everything else; avoids relying on the built-in GITHUB_TOKEN, whose tag-push behavior on this Forgejo version is unverified).

Workflow sketch

Build tool: podman (Arnaud's preference — daemonless and rootless-capable; as a bonus it removes the docker-socket exposure the original docker plan needed).

.forgejo/workflows/image.yml:

name: Build agent image
on:
  workflow_dispatch: {}

jobs:
  build:
    runs-on: docker
    container:
      image: quay.io/podman/stable
      options: --privileged   # nested build; try rootless (--device /dev/fuse, vfs) first, fall back if needed
    steps:
      - name: Checkout (plain git — keeps the container free of node)
        env:
          BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
        run: git clone --depth 1 "https://bot:$BOT_TOKEN@factory.serious-devs.io/serious-devs/ai-factory.git" .
      - name: Compute image tag
        run: echo "TAG=$(date -u +%Y%m%d)-${{ github.run_number }}" >> "$GITHUB_ENV"
      - name: Registry login
        env:
          BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
        run: podman login factory.serious-devs.io -u bot --password-stdin < "$BOT_TOKEN_FILE" 2>/dev/null || podman login factory.serious-devs.io -u bot -p "$BOT_TOKEN"
      - name: Build & push
        run: |
          IMAGE=factory.serious-devs.io/serious-devs/ai-factory
          podman build --storage-driver=vfs -f image/Containerfile -t "$IMAGE:$TAG" -t "$IMAGE:latest" .
          podman push --digestfile /tmp/digest "$IMAGE:$TAG"
          podman push "$IMAGE:latest"
      - name: Report
        run: echo "New agent image: factory.serious-devs.io/serious-devs/ai-factory:$TAG (digest $(cat /tmp/digest))" >> "$GITHUB_STEP_SUMMARY"
      # Second step of the chain, still zero LLM: open a PR bumping the
      # agent-image default in .forgejo/workflows/agent.yml to $TAG
      # (sed the default line, push branch image-bump/$TAG, create the PR
      # through the Forgejo API authenticated as `bot` with BOT_TOKEN).
      # If a bump PR is ALREADY open, force-push its branch to the new tag
      # instead of stacking a second PR (same dedupe pattern as the glue's
      # findPullByHead): N dispatches without a merge in between must end
      # with exactly ONE open bump PR.

Implementation pitfalls to verify against the actual runner:

  1. Job container — quay.io/podman/stable may lack git (Fedora-minimal base): if so, microdnf install -y git in a pre-step. Checkout is a plain git clone with the bot token, so node is NOT needed in the container at all.
  2. Nested build privileges — podman needs a way to handle the overlay filesystem inside the job container: try rootless with fuse (--device /dev/fuse + --storage-driver=vfs) before reaching for --privileged; verify what this act_runner version honors via container.options. The --storage-driver=vfs is slower but the safest inside a container.
  3. Identity & credentials — user bot (now collaborator on this repo): docker login as bot with the org-level secret BOT_TOKEN, and the same token for the git tag push and the bump-PR API call — one identity for everything the workflow does. Verify BOT_TOKEN scopes cover all three: container/package write (registry push), repository write (git tag push + bump branch), and issue write (bump-PR comment if any). If a scope is missing, recreate the PAT with the required scopes — the first dispatch will show it (403 at the failing step). The login also covers pulling the base image factory.serious-devs.io/serious-devs/tsuru:latest referenced by image/Containerfile.
  4. Security note — no docker daemon and no host socket are involved anymore (podman is daemonless), but a privileged or fuse-enabled job container is still needed for the nested build; keep the runner private — consistent with docs/setup.md security notes.

Out of scope

  • No automatic merge, no direct write to agent.yml on main (the bump is a PR).
  • No multi-arch builds, build-cache tuning, or provenance/attestations — add later if ever needed.

Acceptance criteria

  • Manual dispatch of image.yml from the Actions tab succeeds on a clean main
  • The pushed tag matches YYYYMMDD-N, is owned by user bot in the registry, and can be pulled from another machine (podman/docker/skopeo — the registry is OCI-compliant); :latest points at the same digest
  • The run summary shows the registry digest captured via podman push --digestfile, and it matches the digest embedded in the annotated git tag message
  • An agent run using the new image works e2e: on serious-devs/test_factory_ai, dispatch a plan run passing agent-image: factory.serious-devs.io/serious-devs/ai-factory:$TAG as input (no default change needed to test) → job completes, agent replies
  • The workflow opens a bump PR against main changing exactly the agent-image default line in .forgejo/workflows/agent.yml; after merging it, a plain plan dispatch uses the new tag with no input override
  • After a successful dispatch: git tag YYYYMMDD-N exists on the built commit, is annotated, and its message contains the commit SHA and the registry digest
  • A failed image push leaves NO git tag (ordering build → push → tag verified)
  • Two dispatches in a row without merging the bump PRs end with exactly ONE open bump PR, whose branch points at the newest tag
  • The working tag-push path (GITHUB_TOKEN or fallback secret) is documented in a workflow comment
  • docs/setup.md §3 rewritten to describe the manual dispatch instead of the local docker commands, and the file-name drift fixed (image/Containerfile, not image/Dockerfile)
  • The PR that introduces this workflow carries the standard "Wiki updates on merge" section (the Workflow page Plumbing row must gain the new image.yml file at merge time)
## Goal A Forgejo Actions workflow in this repository — `.forgejo/workflows/image.yml` — triggered manually (`workflow_dispatch`, no inputs) that builds the agent image, tags it, pushes it to the instance registry, and reports the new tag. The chain build → tag → push must involve zero LLM calls. ## Why Today this is manual (`docs/setup.md` §3): docker build, docker push, a hand-made tag (`20261013-1`), then a hand edit of the `agent-image` default in `.forgejo/workflows/agent.yml`. Automating the chain removes all of that. Merging stays with @mlvtito and deployment keeps its human gate: the workflow opens a small PR bumping the default tag, and merging that PR is his decision. ## Tag governance (decision) - **Primary tag, immutable per build: `YYYYMMDD-N`** — UTC build date + the workflow run number (e.g. `20261114-3`). Chronological, unique, cannot collide with the legacy hand-made tags. - **`latest`** pushed alongside, always pointing at the newest build — human convenience only. Nothing in this repository may reference `latest` for the agent image: the `agent-image` default stays pinned to immutable tags. (Mirrors the style of the tsuru base image, which is referenced as `latest`.) - **Git tag on the repository, same name as the image tag** (`20261114-N`). The 1:1 string mapping makes the image ↔ workflow-version link greppable — no mapping table to maintain, no prefix to remember. Created **after** the image push succeeds (order: build → push image → create git tag → bump PR), so a git tag can never point at a commit whose image was not published. - The git tag is **annotated**, and its message closes the loop: `git tag -a $TAG -m "agent image $TAG — commit $GITHUB_SHA, registry digest $DIGEST"` (digest captured from the pushed image). One lookup links image ↔ code ↔ digest. - **`latest` never becomes a git tag** — a mutable name has no meaning in git; only the immutable tag is created. - Known caveat, stated once so nobody treats it as a contract: the tag is a pointer, not a reproducibility guarantee — the image is not byte-reproducible from the commit (`npm install` floats). The base image stays on `tsuru:latest` BY DECISION of @mlvtito: tsuru is his own tool, evolves slowly, and only needs to bring the right tool versions — do not open digest-pinning issues for it. - **Semantics note:** the tagged commit still contains the *previous* `agent-image` default (the bump PR is created after the tag). That is correct: the git tag certifies **what was built**, the bump PR certifies **what is deployed**. Two different facts, deliberately kept apart. - **Tag push rights:** push the git tag with `bot` + `BOT_TOKEN` (same identity as everything else; avoids relying on the built-in `GITHUB_TOKEN`, whose tag-push behavior on this Forgejo version is unverified). ## Workflow sketch **Build tool: podman** (Arnaud's preference — daemonless and rootless-capable; as a bonus it removes the docker-socket exposure the original docker plan needed). `.forgejo/workflows/image.yml`: ```yaml name: Build agent image on: workflow_dispatch: {} jobs: build: runs-on: docker container: image: quay.io/podman/stable options: --privileged # nested build; try rootless (--device /dev/fuse, vfs) first, fall back if needed steps: - name: Checkout (plain git — keeps the container free of node) env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: git clone --depth 1 "https://bot:$BOT_TOKEN@factory.serious-devs.io/serious-devs/ai-factory.git" . - name: Compute image tag run: echo "TAG=$(date -u +%Y%m%d)-${{ github.run_number }}" >> "$GITHUB_ENV" - name: Registry login env: BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: podman login factory.serious-devs.io -u bot --password-stdin < "$BOT_TOKEN_FILE" 2>/dev/null || podman login factory.serious-devs.io -u bot -p "$BOT_TOKEN" - name: Build & push run: | IMAGE=factory.serious-devs.io/serious-devs/ai-factory podman build --storage-driver=vfs -f image/Containerfile -t "$IMAGE:$TAG" -t "$IMAGE:latest" . podman push --digestfile /tmp/digest "$IMAGE:$TAG" podman push "$IMAGE:latest" - name: Report run: echo "New agent image: factory.serious-devs.io/serious-devs/ai-factory:$TAG (digest $(cat /tmp/digest))" >> "$GITHUB_STEP_SUMMARY" # Second step of the chain, still zero LLM: open a PR bumping the # agent-image default in .forgejo/workflows/agent.yml to $TAG # (sed the default line, push branch image-bump/$TAG, create the PR # through the Forgejo API authenticated as `bot` with BOT_TOKEN). # If a bump PR is ALREADY open, force-push its branch to the new tag # instead of stacking a second PR (same dedupe pattern as the glue's # findPullByHead): N dispatches without a merge in between must end # with exactly ONE open bump PR. ``` Implementation pitfalls to verify against the actual runner: 1. **Job container** — `quay.io/podman/stable` may lack `git` (Fedora-minimal base): if so, `microdnf install -y git` in a pre-step. Checkout is a plain `git clone` with the `bot` token, so node is NOT needed in the container at all. 2. **Nested build privileges** — podman needs a way to handle the overlay filesystem inside the job container: try rootless with fuse (`--device /dev/fuse` + `--storage-driver=vfs`) before reaching for `--privileged`; verify what this act_runner version honors via `container.options`. The `--storage-driver=vfs` is slower but the safest inside a container. 3. **Identity & credentials — user `bot`** (now collaborator on this repo): docker login as `bot` with the org-level secret `BOT_TOKEN`, and the same token for the git tag push and the bump-PR API call — one identity for everything the workflow does. **Verify `BOT_TOKEN` scopes cover all three**: container/package write (registry push), repository write (git tag push + bump branch), and issue write (bump-PR comment if any). If a scope is missing, recreate the PAT with the required scopes — the first dispatch will show it (403 at the failing step). The login also covers pulling the base image `factory.serious-devs.io/serious-devs/tsuru:latest` referenced by `image/Containerfile`. 4. **Security note** — no docker daemon and no host socket are involved anymore (podman is daemonless), but a privileged or fuse-enabled job container is still needed for the nested build; keep the runner private — consistent with `docs/setup.md` security notes. ## Out of scope - No automatic merge, no direct write to `agent.yml` on `main` (the bump is a PR). - No multi-arch builds, build-cache tuning, or provenance/attestations — add later if ever needed. ## Acceptance criteria - [ ] Manual dispatch of `image.yml` from the Actions tab succeeds on a clean `main` - [ ] The pushed tag matches `YYYYMMDD-N`, is owned by user `bot` in the registry, and can be pulled from another machine (podman/docker/skopeo — the registry is OCI-compliant); `:latest` points at the same digest - [ ] The run summary shows the registry digest captured via `podman push --digestfile`, and it matches the digest embedded in the annotated git tag message - [ ] An agent run using the new image works e2e: on `serious-devs/test_factory_ai`, dispatch a plan run passing `agent-image: factory.serious-devs.io/serious-devs/ai-factory:$TAG` as input (no default change needed to test) → job completes, agent replies - [ ] The workflow opens a bump PR against `main` changing exactly the `agent-image` default line in `.forgejo/workflows/agent.yml`; after merging it, a plain plan dispatch uses the new tag with no input override - [ ] After a successful dispatch: git tag `YYYYMMDD-N` exists on the built commit, is annotated, and its message contains the commit SHA and the registry digest - [ ] A failed image push leaves NO git tag (ordering build → push → tag verified) - [ ] Two dispatches in a row without merging the bump PRs end with exactly ONE open bump PR, whose branch points at the newest tag - [ ] The working tag-push path (`GITHUB_TOKEN` or fallback secret) is documented in a workflow comment - [ ] `docs/setup.md` §3 rewritten to describe the manual dispatch instead of the local docker commands, and the file-name drift fixed (`image/Containerfile`, not `image/Dockerfile`) - [ ] The PR that introduces this workflow carries the standard "Wiki updates on merge" section (the `Workflow` page Plumbing row must gain the new `image.yml` file at merge time)
Author
Collaborator

First dispatch failed at Build and push image (storage driver mismatch: login initialized an overlay DB, build asked for vfs) — issue reopened since the acceptance criteria are not met yet. Fix in PR: use the vfs driver from /etc/containers/storage.conf before any podman command. Re-dispatch after merging it.

First dispatch failed at `Build and push image` (storage driver mismatch: login initialized an `overlay` DB, build asked for `vfs`) — issue reopened since the acceptance criteria are not met yet. Fix in PR: use the vfs driver from /etc/containers/storage.conf before any podman command. Re-dispatch after merging it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
serious-devs/ai-factory#7
No description provided.