Canonical shell-only composite actions: deploy-pages, docker-publish, forgejo-release

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andras Schmelczer 2026-06-06 14:44:53 +01:00
commit 8b0ef6d709
4 changed files with 301 additions and 0 deletions

84
README.md Normal file
View file

@ -0,0 +1,84 @@
# ci-actions
Canonical, self-contained CI building blocks shared across the repos on this
Forgejo instance. Every action is a **composite action implemented in plain
shell** — none of them fetch or run a third-party marketplace action, so the
runner only ever executes code from this repo.
## Why
The publishing steps across the repos were copy-pasted variants of two things:
copying a built site into the `/pages` mount, and building + pushing a Docker
image to the Forgejo registry (plus the occasional release upload). These
actions are the single, vetted implementation of each.
## How repos reference these
Because this Forgejo instance is served under a `/git/` sub-path,
`DEFAULT_ACTIONS_URL` points at `code.forgejo.org` (so bare `actions/checkout`
keeps working). To pull an action from *this* instance, reference it by the
runner-internal URL:
```yaml
- uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
```
The runner (which can reach `forgejo:3000`) fetches it; nothing external is
involved. Pin `@main` for instant propagation, or a tag for stability.
## Actions
### `deploy-pages`
rsync a built directory into `/pages/<target>`.
```yaml
- uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
source: dist/ # contents are synced
target: photos # -> /pages/photos
# delete: "true" # default; set "false" to keep extra files
```
### `docker-publish`
Build and push an image to the Forgejo container registry. Computes tags from
the git ref: `sha-<short>` always, `latest` on the default branch, and
`vX.Y.Z` / `X.Y.Z` / `X.Y` / `X` on semver tags.
```yaml
- uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
with:
context: ./backend # default "."
# dockerfile: path/Dockerfile
# image-suffix: -server # -> <host>/<owner>/<repo>-server
# build-args: |
# BASE_HREF=/towers/
push: ${{ github.event_name != 'pull_request' }}
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
```
Notes:
- Single-architecture build (no buildx multi-arch / registry buildcache).
- An idempotent `docker` CLI install runs if the runner image lacks it.
### `forgejo-release`
Create a release via the Forgejo API and upload assets.
```yaml
# Moving "latest" prerelease on default-branch pushes:
- uses: http://forgejo:3000/andras/ci-actions/forgejo-release@main
if: github.ref == 'refs/heads/master'
with:
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
files: dist/release
tag: latest
prerelease: "true"
override: "true"
# Stable release on a tag push (tag defaults to the pushed ref):
- uses: http://forgejo:3000/andras/ci-actions/forgejo-release@main
if: startsWith(github.ref, 'refs/tags/')
with:
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
files: dist/release
```

33
deploy-pages/action.yml Normal file
View file

@ -0,0 +1,33 @@
name: Deploy to pages mount
description: >-
rsync a built directory into the host /pages mount that nginx serves.
Pure shell — depends on no marketplace actions.
inputs:
source:
description: Source directory whose *contents* are synced (a trailing slash is added automatically).
required: true
target:
description: Subdirectory under /pages to deploy into, e.g. "photos" -> /pages/photos.
required: true
delete:
description: Delete files in the target that are absent from the source ("true"/"false").
default: "true"
runs:
using: composite
steps:
- name: rsync to /pages
shell: bash
env:
SOURCE: ${{ inputs.source }}
TARGET: ${{ inputs.target }}
DELETE: ${{ inputs.delete }}
run: |
set -euo pipefail
command -v rsync >/dev/null 2>&1 || { apt-get update && apt-get install -y --no-install-recommends rsync; }
del=""
[ "$DELETE" = "true" ] && del="--delete"
# Trailing slash on the source => copy its contents, not the dir itself.
src="${SOURCE%/}/"
rsync -a $del --mkpath "$src" "/pages/${TARGET}/"

106
docker-publish/action.yml Normal file
View file

@ -0,0 +1,106 @@
name: Build & publish Docker image
description: >-
Build a Docker image and push it to this Forgejo instance's container
registry. Reimplements docker/login-action + docker/metadata-action +
docker/build-push-action in plain shell using the docker CLI — no
marketplace actions are fetched or run.
inputs:
context:
description: Docker build context.
default: "."
dockerfile:
description: Path to the Dockerfile (relative to the workspace). Empty = <context>/Dockerfile.
default: ""
image-suffix:
description: Appended to the derived image name <host>/<owner>/<repo>, e.g. "-server".
default: ""
build-args:
description: Newline-separated KEY=VALUE pairs passed as --build-arg.
default: ""
push:
description: Whether to log in and push ("true"/"false"). Set false for pull-request builds.
default: "true"
token:
description: Registry password/token (e.g. secrets.FORGEJO_PACKAGE_TOKEN).
required: true
registry-host:
description: Override the registry host. Defaults to github.server_url (vars.CONTAINER_REGISTRY_HOST is honoured if set in the env).
default: ""
runs:
using: composite
steps:
- name: Build and publish
shell: bash
env:
INPUT_CONTEXT: ${{ inputs.context }}
INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
INPUT_IMAGE_SUFFIX: ${{ inputs.image-suffix }}
INPUT_BUILD_ARGS: ${{ inputs.build-args }}
INPUT_PUSH: ${{ inputs.push }}
INPUT_TOKEN: ${{ inputs.token }}
INPUT_REGISTRY_HOST: ${{ inputs.registry-host }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
SERVER_URL: ${{ github.server_url }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# --- Ensure the docker CLI exists (idempotent; the node:* runner image may lack it) ---
if ! command -v docker >/dev/null 2>&1; then
ARCH="$(uname -m)"
curl -fsSL --retry 3 --retry-connrefused \
"https://download.docker.com/linux/static/stable/${ARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
fi
docker --version
# --- Resolve the registry host (canonical form of the block copy-pasted across repos) ---
host="${INPUT_REGISTRY_HOST:-${CONTAINER_REGISTRY_HOST:-$SERVER_URL}}"
host="${host#https://}"; host="${host#http://}"; host="${host%/}"
# The job's docker daemon reaches the registry via the host-published port, not the
# internal service name; this is the same remap the old inline scripts performed.
[ "$host" = "forgejo:3000" ] && host="127.0.0.1:13000"
repo="$(printf '%s' "$REPO" | tr '[:upper:]' '[:lower:]')"
image="${host}/${repo}${INPUT_IMAGE_SUFFIX}"
echo "Target image: ${image}"
# --- Compute tags from the git ref (replaces docker/metadata-action) ---
tags=( -t "${image}:sha-$(printf '%s' "$SHA" | cut -c1-12)" )
if [ "$REF" = "refs/heads/${DEFAULT_BRANCH}" ]; then
tags+=( -t "${image}:latest" )
fi
if [ "$REF_TYPE" = "tag" ]; then
tags+=( -t "${image}:${REF_NAME}" )
if printf '%s' "$REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
v="${REF_NAME#v}"
tags+=( -t "${image}:${v}" -t "${image}:${v%.*}" -t "${image}:${v%%.*}" )
fi
fi
# --- Assemble extra build flags ---
extra=()
[ -n "${INPUT_DOCKERFILE}" ] && extra+=( -f "${INPUT_DOCKERFILE}" )
while IFS= read -r a; do
[ -n "$a" ] && extra+=( --build-arg "$a" )
done <<< "${INPUT_BUILD_ARGS}"
# --- Build ---
docker build "${extra[@]}" \
--label "org.opencontainers.image.source=${SERVER_URL}/${REPO}" \
--label "org.opencontainers.image.revision=${SHA}" \
--label "org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
"${tags[@]}" \
"${INPUT_CONTEXT}"
# --- Push (skipped on pull requests by passing push: false) ---
if [ "${INPUT_PUSH}" = "true" ]; then
printf '%s' "${INPUT_TOKEN}" | docker login "$host" -u "$OWNER" --password-stdin
docker push --all-tags "$image"
fi

View file

@ -0,0 +1,78 @@
name: Publish a Forgejo release
description: >-
Create (or replace) a release on this Forgejo instance and upload assets,
using the Forgejo API via curl. Reimplements forgejo-release / the
hand-rolled curl uploads — no marketplace actions.
inputs:
token:
description: API token with write access (e.g. secrets.FORGEJO_PACKAGE_TOKEN or the workflow token).
required: true
files:
description: A directory (all regular files within are uploaded) or a glob of asset files.
required: true
tag:
description: Release tag. Defaults to the pushed ref name (github.ref_name).
default: ""
draft:
description: Create the release as a draft ("true"/"false").
default: "false"
prerelease:
description: Mark the release as a prerelease ("true"/"false").
default: "false"
override:
description: >-
Delete any existing release (and its tag) with the same tag first, then recreate it
at the current commit. Use for moving tags such as "latest".
default: "false"
runs:
using: composite
steps:
- name: Create release and upload assets
shell: bash
env:
TOKEN: ${{ inputs.token }}
FILES: ${{ inputs.files }}
IN_TAG: ${{ inputs.tag }}
DRAFT: ${{ inputs.draft }}
PRERELEASE: ${{ inputs.prerelease }}
OVERRIDE: ${{ inputs.override }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
command -v jq >/dev/null 2>&1 || { apt-get update && apt-get install -y --no-install-recommends jq curl; }
tag="${IN_TAG:-$REF_NAME}"
api="${SERVER_URL}/api/v1/repos/${REPO}"
auth=(-H "Authorization: token ${TOKEN}")
# Override: remove an existing release+tag so a moving tag (e.g. latest) is recreated cleanly.
if [ "$OVERRIDE" = "true" ]; then
rid="$(curl -fsS "${auth[@]}" "${api}/releases/tags/${tag}" 2>/dev/null | jq -r '.id // empty' || true)"
[ -n "$rid" ] && curl -fsS -X DELETE "${auth[@]}" "${api}/releases/${rid}" >/dev/null 2>&1 || true
curl -fsS -X DELETE "${auth[@]}" "${api}/tags/${tag}" >/dev/null 2>&1 || true
fi
# Create the release. target_commitish pins a not-yet-existing tag to this commit.
payload="$(jq -nc --arg t "$tag" --arg c "$SHA" \
--argjson d "$DRAFT" --argjson p "$PRERELEASE" \
'{tag_name:$t, name:$t, target_commitish:$c, draft:$d, prerelease:$p}')"
rid="$(curl -fsS -X POST "${auth[@]}" -H "Content-Type: application/json" \
"${api}/releases" -d "$payload" | jq -r '.id')"
echo "Created release ${tag} (id ${rid})"
# Collect assets: a directory expands to its contents, otherwise treat the input as a glob.
shopt -s nullglob
if [ -d "$FILES" ]; then set -- "$FILES"/*; else set -- $FILES; fi
for f in "$@"; do
[ -f "$f" ] || continue
name="$(basename "$f")"
curl -fsS -X POST "${auth[@]}" \
"${api}/releases/${rid}/assets?name=${name}" \
-F "attachment=@${f};type=application/octet-stream" >/dev/null
echo "uploaded ${name}"
done