Create reusable runner image build action

This commit is contained in:
Meghdad
2026-07-17 23:02:13 +03:30
commit 89142d5571
5 changed files with 314 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
.idea/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Meghdad Fadaee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# Build Runner Image Action
A reusable composite action for Gitea Actions that builds a Docker runner image,
runs project-specific verification commands, and publishes both an immutable Git
revision tag and `latest` to a private Gitea Container Registry.
The action is hosted on Gitea and does not use GitHub. Call it with an absolute
URL, which Gitea requires for actions hosted outside the configured default
action source:
```yaml
- name: Build, verify, and publish image
uses: https://mahgit.ir/MeghdadFadaee/build-runner-image-action@v1
with:
registry: mahgit.ir
image: mahgit.ir/example/project-runner
source-url: https://mahgit.ir/example/project
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-token: ${{ secrets.REGISTRY_TOKEN }}
verify-commands: |
docker run --rm "$IMAGE_REF" node --version
docker run --rm "$IMAGE_REF" python3 --version
```
`IMAGE_REF` is available only to `verify-commands` and contains the immutable
candidate image reference. The action publishes `latest` only after every
verification command succeeds.
## Default caller layout
```text
project/
├── .dockerignore
├── requirements.lock
└── .gitea/workflows/build-runner-image.yml
```
The caller does not need a Dockerfile. Use this `.dockerignore` so the root
build context sends only the dependency lock to Docker:
```dockerignore
**
!requirements.lock
```
If `requirements-file` points somewhere else, update the exception to include
that path.
## Inputs
| Input | Required | Default | Description |
| --- | --- | --- | --- |
| `registry` | Yes | - | Registry hostname |
| `image` | Yes | - | Fully qualified image name without a tag |
| `base-image` | No | Gitea-hosted `node-20-bookworm` | Base image override |
| `context` | No | `.` | Docker build context |
| `requirements-file` | No | `requirements.lock` | Lock path relative to the context |
| `dockerfile` | No | Shared Dockerfile | Optional caller Dockerfile override |
| `source-url` | Yes | - | OCI source-label URL |
| `registry-username` | Yes | - | Gitea registry username |
| `registry-token` | Yes | - | PAT with package Read and Write permission |
| `verify-commands` | No | Empty | Trusted shell commands using `IMAGE_REF` |
## Outputs
| Output | Description |
| --- | --- |
| `image` | Image name without a tag |
| `revision` | Twelve-character Git revision |
| `revision_tag` | Immutable published image reference |
| `latest_tag` | Published `latest` image reference |
## Runner requirements
- The caller must check out its repository before invoking this action.
- The default Dockerfile expects a fully pinned `requirements.lock` at the
build-context root.
- The job container must be Debian-based or already include a Docker client.
- The Docker daemon socket must be available to the job container.
- The runner should use `container.force_pull: true`.
- Only trusted repositories should use a runner with Docker socket access.
The action installs the Docker client only when it is absent. It sends the
registry token through standard input, logs out on exit, builds with `--pull`,
sets OCI revision/source labels, verifies the candidate, then publishes the
immutable and moving tags.
The default Dockerfile lives at `runner-image/Dockerfile` in this action. It
installs the shared Debian, Git, Python, virtual-environment, and timezone
dependencies. A caller can provide `requirements-file` for a differently named
lock inside its build context, or `dockerfile` when it needs different system
packages.
The default base is:
```text
mahgit.ir/meghdadfadaee/build-runner-image-base:node-20-bookworm
```
Use `base-image` only when a project needs a different base.
## Versioning
Create immutable releases such as `v1.0.0` and move the major `v1` tag only for
backward-compatible releases. Caller workflows should use `@v1` rather than
`@main`.

159
action.yml Normal file
View File

@@ -0,0 +1,159 @@
name: Build and publish runner image
description: Build, verify, and publish a runner image to a private Gitea registry.
inputs:
registry:
description: Registry hostname, for example mahgit.ir.
required: true
image:
description: Fully qualified image name without a tag.
required: true
base-image:
description: Base image used by the default Dockerfile.
required: false
default: mahgit.ir/meghdadfadaee/build-runner-image-base:node-20-bookworm
context:
description: Docker build context relative to the checked-out repository.
required: false
default: .
requirements-file:
description: Requirements lock path relative to the Docker build context.
required: false
default: requirements.lock
dockerfile:
description: Optional caller Dockerfile path; defaults to the action Dockerfile.
required: false
default: ""
source-url:
description: Repository URL stored in the OCI source label.
required: true
registry-username:
description: Registry username.
required: true
registry-token:
description: Registry token with package Read and Write permission.
required: true
verify-commands:
description: Shell commands that verify the candidate image using IMAGE_REF.
required: false
default: ""
outputs:
image:
description: Fully qualified image name without a tag.
value: ${{ steps.publish.outputs.image }}
revision:
description: Twelve-character Git revision used as the immutable tag.
value: ${{ steps.publish.outputs.revision }}
revision_tag:
description: Fully qualified immutable image reference.
value: ${{ steps.publish.outputs.revision_tag }}
latest_tag:
description: Fully qualified latest image reference.
value: ${{ steps.publish.outputs.latest_tag }}
runs:
using: composite
steps:
- name: Install Docker client
shell: sh
run: |
set -eu
if command -v docker >/dev/null 2>&1; then
docker version --format 'Docker client {{.Client.Version}}, server {{.Server.Version}}'
exit 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "Docker is unavailable and this job image does not provide apt-get"
exit 1
fi
for attempt in 1 2 3; do
if apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends docker.io; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "Unable to install the Docker client after 3 attempts"
exit 1
fi
sleep "$((attempt * 5))"
done
rm -rf /var/lib/apt/lists/*
docker version --format 'Docker client {{.Client.Version}}, server {{.Server.Version}}'
- name: Build, verify, and publish image
id: publish
shell: sh
env:
INPUT_REGISTRY: ${{ inputs.registry }}
INPUT_IMAGE: ${{ inputs.image }}
INPUT_BASE_IMAGE: ${{ inputs.base-image }}
INPUT_CONTEXT: ${{ inputs.context }}
INPUT_REQUIREMENTS_FILE: ${{ inputs.requirements-file }}
INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
INPUT_SOURCE_URL: ${{ inputs.source-url }}
INPUT_REGISTRY_USERNAME: ${{ inputs.registry-username }}
INPUT_REGISTRY_TOKEN: ${{ inputs.registry-token }}
INPUT_VERIFY_COMMANDS: ${{ inputs.verify-commands }}
ACTION_PATH: ${{ gitea.action_path }}
run: |
set -eu
: "${INPUT_REGISTRY:?registry is required}"
: "${INPUT_IMAGE:?image is required}"
: "${INPUT_BASE_IMAGE:?base-image is required}"
: "${INPUT_CONTEXT:?context is required}"
: "${INPUT_REQUIREMENTS_FILE:?requirements-file is required}"
: "${INPUT_SOURCE_URL:?source-url is required}"
: "${INPUT_REGISTRY_USERNAME:?registry-username is required}"
: "${INPUT_REGISTRY_TOKEN:?registry-token is required}"
revision="$(git rev-parse --short=12 HEAD)"
candidate="$INPUT_IMAGE:$revision"
latest="$INPUT_IMAGE:latest"
dockerfile="$INPUT_DOCKERFILE"
if [ -z "$dockerfile" ]; then
dockerfile="$ACTION_PATH/runner-image/Dockerfile"
fi
if [ ! -f "$dockerfile" ]; then
echo "Dockerfile does not exist: $dockerfile"
exit 1
fi
cleanup() {
docker logout "$INPUT_REGISTRY" >/dev/null 2>&1 || true
}
trap cleanup EXIT
printf '%s' "$INPUT_REGISTRY_TOKEN" | docker login "$INPUT_REGISTRY" \
--username "$INPUT_REGISTRY_USERNAME" \
--password-stdin
docker build \
--pull \
--label "org.opencontainers.image.revision=$revision" \
--label "org.opencontainers.image.source=$INPUT_SOURCE_URL" \
--build-arg "BASE_IMAGE=$INPUT_BASE_IMAGE" \
--build-arg "REQUIREMENTS_FILE=$INPUT_REQUIREMENTS_FILE" \
--file "$dockerfile" \
--tag "$candidate" \
"$INPUT_CONTEXT"
if [ -n "$INPUT_VERIFY_COMMANDS" ]; then
export IMAGE_REF="$candidate"
sh -eu -c "$INPUT_VERIFY_COMMANDS"
fi
docker push "$candidate"
docker tag "$candidate" "$latest"
docker push "$latest"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "image=$INPUT_IMAGE"
echo "revision=$revision"
echo "revision_tag=$candidate"
echo "latest_tag=$latest"
} >> "$GITHUB_OUTPUT"
fi

26
runner-image/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
git \
python3 \
python3-pip \
python3-venv \
tzdata && \
rm -rf /var/lib/apt/lists/*
ARG REQUIREMENTS_FILE=requirements.lock
COPY ${REQUIREMENTS_FILE} /tmp/requirements.lock
RUN python3 -m venv /opt/runner-venv && \
/opt/runner-venv/bin/pip install --no-cache-dir -r /tmp/requirements.lock && \
/opt/runner-venv/bin/pip check && \
rm /tmp/requirements.lock
ENV PATH="/opt/runner-venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE="1" \
PYTHONUNBUFFERED="1"
CMD ["bash"]