The external provider action library for pf-ci. Each provider name maps to one composite action, pinned and consumed as /actions/@v1 — the owner being this library’s mirror on whichever forge the workflow runs on; the resolver keeps the dispatch while this library owns the native CI recipe for each target (Ports and Adapters).
  • Shell 97%
  • Awk 3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-26 13:45:49 -03:00
container-build fix(container-build): expire the buildah image artifact after one day 2026-10-26 13:45:49 -03:00
container-exec fix(lint): run yamllint --strict in lefthook and clear warnings 2026-10-02 22:09:41 -03:00
container-load fix(lint): run yamllint --strict in lefthook and clear warnings 2026-10-02 22:09:41 -03:00
docker-cleanup fix(lint): run yamllint --strict in lefthook and clear warnings 2026-10-02 22:09:41 -03:00
forgejo-release feat(gh-release): add the GitHub release action; forgejo-release reads RELEASE_TARGET 2026-10-16 04:53:16 -03:00
gh-release style(gh-release): keep the env comment under the line limit 2026-10-18 17:33:31 -03:00
LICENSES feat(buildah): optional M6E_BUILDAH_MEMORY per-build cap 2026-08-01 15:49:27 -03:00
oci fix(oci-push): list registry tags through the absolute path of the running bash 2026-10-15 20:52:24 -03:00
oci-push feat(oci-push): add withhold-arches input 2026-10-25 15:45:44 -03:00
preflight fix(preflight): report a PATH binary as unchecked instead of warning 2026-10-21 14:12:38 -03:00
run-tool fix(run-tool): trust the workspace bind so git reads .gitignore under a root job 2026-10-09 18:58:22 -03:00
secrets-provision fix(secrets-provision): run generators with B19_SECRETS_ENABLED=false 2026-10-04 02:31:12 -03:00
store fix(store): quote fallback in retry tee assignment 2026-10-03 23:02:23 -03:00
.yamllint fix(lint): run yamllint --strict in lefthook and clear warnings 2026-10-02 22:09:41 -03:00
CITATION.cff chore(docs): add cffr 2026-09-13 15:06:19 -03:00
CONTRIBUTING.md docs: rename the library to projectfile/actions 2026-09-02 01:13:21 -03:00
lefthook.yaml feat(preflight): add a host-side images, hosts and disk probe 2026-10-18 16:41:20 -03:00
LICENSE feat(buildah): optional M6E_BUILDAH_MEMORY per-build cap 2026-08-01 15:49:27 -03:00
projectfile.yaml chore: optimize projectfile, clear links 2026-10-16 22:53:49 -03:00
README.md feat(preflight): add a host-side images, hosts and disk probe 2026-10-18 16:41:20 -03:00

projectfile/actions

The external provider library for pf-ci. One composite action per provider name, pinned and consumed as <owner>/actions/<provider>@v1.

Why this repo exists (Ports & Adapters)

pf-ci lowers an org.projectfile.ci signal DAG to vendor CI workflows. A provider leaf (e.g. container-build) is not a portable "run a tool in a box" step — it is realised with the platform's own native mechanism. Compiling those recipes into the resolver as Go template partials coupled the vendor-neutral resolver to one target's mechanics and made the recipes un-reviewable as a unit.

So the resolver keeps the dispatch (provider name + DAG ordering) and this library keeps the recipe. A provider leaf lowers to:

- uses: projectfile-org/actions/container-build/buildx@v1
  with:
    artifact-name: image-${{ matrix.B19_UBUNTU_SERIES }}
    build-args: |
      B19_UBUNTU_SERIES=${{ matrix.B19_UBUNTU_SERIES }}

Adding a provider becomes "publish an action here," not "edit + recompile the resolver."

Build backends (container-build)

container-build ships two drop-in analogues that emit the identical OCI-tar hand-off, so everything downstream is backend-agnostic:

Backend Mechanism Use
container-build/buildx docker buildx (daemon) GHA/Forgejo default (runner-native)
container-build/buildah buildah (daemonless) engines without a daemon (Tekton), rootless

The backend is a HOW-it-runs detail, not part of the gate's meaning, so pf-ci selects it per target (a Target adapter default) overridable per project via org.projectfile.ci.<target>.builder: buildx|buildah — never in the neutral DAG. Both write <artifact-name>.tar as an OCI archive.

container-build/
├── build-args.sh        shared: newline build-args → --build-arg array (sourced)
├── mount-cache.{sh,awk} shared: the cache-mount dance (sourced) and its Dockerfile rewrite
├── mount-cache-evict.sh keeps one mount-cache entry per cell
├── buildx/{action.yml, build.sh}    docker buildx backend
└── buildah/{action.yml, build.sh}   daemonless buildah backend

Each action's shell lives in a build.sh (not inlined in YAML, so shellcheck lints it directly); the identical arg-parsing is sourced from build-args.sh via $GITHUB_ACTION_PATH/../build-args.sh.

The registry plane shares the same way. oci/ holds the pieces the publish is assembled from — sinks.sh (the destination route), auth.sh (the sink-keyed credential lookup and login), tags.sh (the semver cascade and variant tags, tested by tags.test.sh) and retry.sh (the bounded backoff around one registry crossing) — each sourced, never executed, so a failure returns into the caller's set -e.

There is no live action (dissolved into run-steps)

live brings the just-built image to LIFE and exercises it, but it is not an action in this library — the fuse model dissolved it. The DAG marks dc-up-d + container-test with fuse: live; because a compose stack cannot span jobs on an ephemeral runner, pf-ci COLLAPSES those leaves into ONE job and renders each member's own run: command as an ordered step (render.go → steps/fused):

# generated by pf-ci — no `uses:` here
- run: docker load --input image-${{ matrix.B19_UBUNTU_SERIES }}.tar
- run: docker compose up -d --wait
- run: docker compose exec -T app test.d

The recipe is therefore transparent in the generated workflow, not hidden behind a pinned action. Two consequences worth noting:

  • The OCI-load seam stays: container-build stamps the resolved ref into a type=oci archive (kept OCI so the daemonless image-scan can read the same tar), so the fused job's docker load restores it TAGGED and compose's pull_policy: never finds it — no skopeo, no anonymous image.
  • Daemon-only, daemonless-agnostic: the fused run-steps need a daemon (buildah has no compose), but they are plain shell — no backend split, nothing to mirror to a forge as an action. m6e runs the SAME leaves as plain make recipes (its dev daemon persists between jobs, so no fusion is needed there).

Forge portability

uses: is not forge-portable: a GitHub Actions runner resolves <owner>/actions/<name>@v1 against github.com, a Forgejo runner against its own instance. This library is therefore mirrored to each forge the generated workflows run on.

The mirrors do not share an owner. A self-hosted forge lets you pick the owner freely (projectfile on kiota.ch); on github.com that name may already be taken, so the mirror lives elsewhere (projectfile-org). A bare ref that resolves on one forge therefore 404s on the other — and GitHub reports it at Set up job, before checkout, so every job in the run dies at once with Unable to resolve action.

The coordinate is a per-target adapter token in pf-ci (Target.ActionLib / ActionVer) defaulting to the Forgejo one. A target whose mirror sits under a different owner overrides it with org.projectfile.ci.<target>.library, a repo@tag pin best authored once in a shared include so the fleet stays single-source.

Pinning

Consumers pin @v1 (a moving major tag the library keeps backward-compatible within a major). This matches the workspace pinned-dependency rule.

v1 moves only when you move it. A merge to main changes nothing that CI runs, because every consumer resolves the tag, not the branch. Release a fix in two steps:

git push origin main
git tag --sign --force --message 'v1' v1 <commit>
git push --force origin v1

A fix that looks landed but still does not run is almost always a v1 that stayed behind.

Actions

Provider Status Realisation
container-build/buildx landed docker/setup-buildx-action → OCI-tar → cell artifact
container-build/buildah landed buildah build → oci-archive: tar → cell artifact
oci-push landed skopeo copy cell tar → registry (no daemon load); with archives:, buildah indexes the per-arch tars into one manifest list per cascade tag; with heads: and variant:, the tags follow the rules the project declares; retry+backoff on the crossing
image-scan planned scanner against oci-archive:<artifact>.tar (daemonless)
secrets-provision landed org.projectfile.ci.secrets declarations → .secrets/ tree (value-write / docker-run dispatcher)
preflight landed host-side probe, no tool image: images resolves each ref to a digest (skopeo), checks its version range and org.projectfile.provides commands and caches the verdict by digest; hosts opens each host:port; disk checks free space

live is intentionally absent — it is rendered as fused run-steps by pf-ci, not dispatched here (see "There is no live action").

secrets-provision is the cloud half of the org.projectfile.ci.secrets provisioning contract (m6e/secrets-system.md): pf-ci serializes the secrets: subtree as JSON into declarations:, and this action materialises the .secrets/<dotted-name> tree a compose secrets: block mounts BEFORE dc-up-d. The dispatch is a thin loop — value: writes a literal verbatim, docker: runs docker run <resolved-image> <run> and captures stdout — so a new secret kind is a recipe in the d9t/misc-tools image (m6e-secret-*, where openssl/htpasswd live) plus a docker: { run: ... } declaration, with no action-library or pf-ci redeploy. No outputs: secrets are FILES written into the workspace (the intra-job file hand-off). The m6e half reads the SAME declarations and calls the same dispatch logic.

The build→scan hand-off is an OCI archive tar (<artifact-name>.tar): no registry, no credentials for the intra-pipeline hop. oci-push is the CONSUMER end of that same tar at publish time — pf-ci emits the download-artifact step, then this action logs in and copies the tar straight to the registry with skopeo (skopeo copy docker-archive:<tar> docker://<ref>), never loading it into the daemon’s store (the old load+retag+push was the single biggest disk-bloat source on the shared runner). It is the one action that takes credentials, and only by NAME: the leaf declares env: [REGISTRY_USERNAME, REGISTRY_PASSWORD] and pf-ci's per-target credentials overlay binds those NAMES to secret refs on the push job (a composite action's bash reads the job's OS env, never the secrets context). The push ref is the basename container-build stamped into the tar; a registry: input (the leaf’s registry: var) re-prefixes it under a private host, ABSENT keeps the Docker Hub default. The copy is wrapped in a bounded retry+exponential-backoff loop (M6E_OCI_RETRIES / M6E_OCI_BACKOFF), each attempt capped in time (M6E_OCI_TIMEOUT, default 900s) so a registry that accepts the connection and then stops answering fails instead of hanging the release.

run-tool: one registry round trip per run, not per step

Tool images ride mutable tags, so run-tool re-checks the registry on every step (--pull always). That never re-downloads a layer — an unchanged digest moves about half a megabyte — but it does cost a manifest round trip per step, and a job runs dozens of tool steps over a handful of images.

run-tool therefore memos each ref it pulls under $XDG_CACHE_HOME/run-tool/pull, keyed by the workflow run. The first step to name an image pulls it and records the run id; every later step in the same run reuses the warm store. Freshness across runs is unchanged — a new run id is always a miss, so a freshly pushed tool image is picked up by the next run. A memo cannot be scoped without a run id, so an invocation with no GITHUB_RUN_ID (a local shell) re-checks every time.

The pull is also quiet, so the per-blob copy wall no longer buries the log: one run-tool pull-memo … line names the ref and the decision instead.

Runner env Default Effect
RUN_TOOL_PULL_MEMO on off restores a registry re-check on every step
RUN_TOOL_PULL_TTL 0 Seconds a memo also stays valid ACROSS runs; 0 keeps it run-local
B19_VERBOSITY warn debug restores the per-blob copy trace

Multi-arch: many archives, one index, no extra tags (archives:)

A project declaring org.projectfile.architecture builds ONE cell per architecture, each emitting the same single-image docker-archive tar the two scanners, the live test and oci-push are all built around — the archive never learns to carry more, there are simply more archives. oci-push then publishes those archives together: archives: carries one <arch> <artifact-name> line per declared architecture, buildah assembles them into a manifest list locally, and manifest push --all uploads the members and the index in one crossing, to every tag of the semver cascade.

The consequence that matters is what the registry does not gain. Members reach it addressed by digest, so the tag list holds 1.2.3, 1.2, 1 and latest — every one of them multi-arch — and no 1.2.3-amd64 scaffolding. Publishing each architecture under its own tag first and indexing them afterwards also works, and was the earlier shape here; it was abandoned because the scaffolding is permanent. Most registries cannot delete a tag at all (this workspace’s own logs Registry does not implement RepositoryRemover), and Docker Hub needs a non-registry API for it, so a -<arch> tag published once is published forever.

Empty archives: — the fleet default, since most projects declare no architecture — is the single-image path unchanged: one skopeo copy per cascade tag, byte for byte what it has always published.

Verification follows the registry-is-the-only-witness rule the single-image path already applies to its config, at both ends. Before any upload, the assembled list must carry exactly the declared architecture set — buildah reads each member’s arch from that member’s own config, so a cell that built the wrong one yields a well-formed index over wrong images. After the first push, skopeo inspect --raw on the published ref must return an index with that same set, and each member’s runtime config must equal the config in its tar. Nothing upstream can catch either: every per-arch archive is individually correct and verifies clean.

Cache mounts survive an ephemeral runner (mount-cache-key:)

A persistent builder keeps every RUN --mount=type=cache between runs — apt lists, source downloads, ccache, the language package caches. An ephemeral runner throws the builder away with the job, so each run compiles from nothing. container-build/buildx closes that gap when a caller hands it a mount-cache-key: stem: it restores the cell’s newest entry with actions/cache/restore, injects the contents into the builder’s mounts before the build, extracts them after, saves under <stem><run_id>, and deletes the entries that key supersedes so a cell never holds more than one. Extraction only follows a successful build, and a tag run restores without saving — its entry would be scoped to the tag and no later run could read it.

Nothing about the mounts is declared twice. mount-cache.awk rewrites the project’s own Dockerfile into a throwaway dance Dockerfile: every stage keeps its FROM, ARG and ENV lines and each source RUN becomes one RUN that carries the very same --mount=type=cache,… flags and tars each target out to (or back in from) a file named by the mount’s resolved id. BuildKit expands those flags against the same base image and build args, so a target=${B19_DOWNLOAD_PATH} or id=apt-cache-${B19_UBUNTU_SERIES}-${TARGETARCH} resolves — ENV inherited from the base included — to the identical cache record the real build uses, with sharing, uid, gid and mode untouched. Ownership rides inside the tars, restored by tar as root, so a cache a non-root pip/uv wrote is still theirs after a round trip. Only the stages the build target reaches dance, one dance per distinct mount flag, and a Dockerfile with no cache mount dances nothing.

The saved size per mount lands in the step summary. Eviction needs the job’s token to carry actions: write; without it the step warns and leaves GitHub’s own oldest-first eviction to bound the repository. mount-cache-restore-keys: lists further stems (newline-separated) a cell with no entry of its own falls back to — pf-ci passes the per-image stem, so a new series or architecture starts from a sibling’s downloads instead of cold. The buildah backend runs on persistent forge runners and takes neither input.