External provider action library for the projectfile ci-resolver
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Damián Búho 39270bb7cb
perf(run-tool): memo an always pull to once per ref per run
Every run-tool step re-checked the registry, because tool images ride
mutable tags. The check never moved a layer — measured 553 KB against a
635 MB image — but it cost a manifest round trip each time: 3.7s under
--pull always against 1.6s under --pull missing, on a job that runs 32
tool steps over 11 distinct images.

Split the implicit pull out of `docker run` so a fetch that really
happened can be recorded, memo the ref under XDG_CACHE_HOME keyed by
GITHUB_RUN_ID, and downgrade a later step in the same run to --pull
missing. Freshness across runs is unchanged: a new run id is always a
miss, so the next run still picks up a freshly pushed tool image.
RUN_TOOL_PULL_TTL widens a memo across runs; RUN_TOOL_PULL_MEMO=off
restores the per-step check.

The memo is written by atomic rename, so concurrent jobs sharing the
runner need no lock. The explicit pull is quiet, so the per-blob copy
wall no longer buries the log — B19_VERBOSITY=debug restores it. An
unwritable cache, an absent GITHUB_RUN_ID and a failed pull each
degrade to the previous behaviour instead of failing the step.
2026-09-10 01:47:05 -03:00
container-build feat(container-build): register a missing QEMU handler before the build 2026-09-09 12:30:47 -03:00
container-exec chore: rename ci-resolver to pf-ci 2026-08-29 22:24:58 -03:00
container-load chore: rename ci-resolver to pf-ci 2026-08-29 22:24:58 -03:00
docker-cleanup chore: rename ci-resolver to pf-ci 2026-08-29 22:24:58 -03:00
forgejo-release feat(forgejo-release): attach the .asc sidecar 2026-09-05 13:43:40 -03:00
LICENSES feat(buildah): optional M6E_BUILDAH_MEMORY per-build cap 2026-08-01 15:49:27 -03:00
oci feat(oci-push): publish a fixed edge tag from the trunk 2026-09-08 18:55:51 -03:00
oci-push style(oci-push): rewrap the branch-publish paragraph 2026-09-08 19:06:58 -03:00
run-tool perf(run-tool): memo an always pull to once per ref per run 2026-09-10 01:47:05 -03:00
secrets-provision docs: rename the library to projectfile/actions 2026-09-02 01:13:21 -03:00
store refactor(container-build): extract the store-race retry into a shared lib 2026-08-19 18:12:33 -03:00
.yamllint fix(lint): restate the fleet yamllint ruleset 2026-08-29 11:57:25 -03:00
CONTRIBUTING.md docs: rename the library to projectfile/actions 2026-09-02 01:13:21 -03:00
lefthook.yaml fix(lefthook): follow sourced files in the shellcheck gate 2026-09-06 04:13:32 -03:00
LICENSE feat(buildah): optional M6E_BUILDAH_MEMORY per-build cap 2026-08-01 15:49:27 -03:00
projectfile.yaml docs: rename the library to projectfile/actions 2026-09-02 01:13:21 -03:00
README.md perf(run-tool): memo an always pull to once per ref per run 2026-09-10 01:47:05 -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)
├── 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 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.gosteps/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; 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)

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 daemons 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 leafs 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 workspaces 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 members arch from that members 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 members runtime config must equal the config in its tar. Nothing upstream can catch either: every per-arch archive is individually correct and verifies clean.