From 89142d5571166250eb7279e60e32baaf9a41881c Mon Sep 17 00:00:00 2001 From: Meghdad <61308304+MeghdadFadaee@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:02:13 +0330 Subject: [PATCH] Create reusable runner image build action --- .gitignore | 2 + LICENSE | 21 ++++++ README.md | 106 +++++++++++++++++++++++++++ action.yml | 159 ++++++++++++++++++++++++++++++++++++++++ runner-image/Dockerfile | 26 +++++++ 5 files changed, 314 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 action.yml create mode 100644 runner-image/Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af56f61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59f8ffb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d96de0 --- /dev/null +++ b/README.md @@ -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`. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..d542bd3 --- /dev/null +++ b/action.yml @@ -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 diff --git a/runner-image/Dockerfile b/runner-image/Dockerfile new file mode 100644 index 0000000..27313de --- /dev/null +++ b/runner-image/Dockerfile @@ -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"]