- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| container-build | ||
| container-exec | ||
| container-load | ||
| docker-cleanup | ||
| forgejo-release | ||
| LICENSES | ||
| oci | ||
| oci-push | ||
| run-tool | ||
| secrets-provision | ||
| store | ||
| .yamllint | ||
| CONTRIBUTING.md | ||
| lefthook.yaml | ||
| LICENSE | ||
| projectfile.yaml | ||
| README.md | ||
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.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-buildstamps the resolved ref into atype=ociarchive (kept OCI so the daemonlessimage-scancan read the same tar), so the fused job'sdocker loadrestores it TAGGED andcompose'spull_policy: neverfinds 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
makerecipes (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 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.