From 8b0ef6d709b70bfd0dc84676abc1fef946f200e3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 6 Jun 2026 14:44:53 +0100 Subject: [PATCH] Canonical shell-only composite actions: deploy-pages, docker-publish, forgejo-release Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 84 +++++++++++++++++++++++++++++ deploy-pages/action.yml | 33 ++++++++++++ docker-publish/action.yml | 106 +++++++++++++++++++++++++++++++++++++ forgejo-release/action.yml | 78 +++++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 README.md create mode 100644 deploy-pages/action.yml create mode 100644 docker-publish/action.yml create mode 100644 forgejo-release/action.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..453b97f --- /dev/null +++ b/README.md @@ -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/`. + +```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-` 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 # -> //-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 +``` diff --git a/deploy-pages/action.yml b/deploy-pages/action.yml new file mode 100644 index 0000000..bafa529 --- /dev/null +++ b/deploy-pages/action.yml @@ -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}/" diff --git a/docker-publish/action.yml b/docker-publish/action.yml new file mode 100644 index 0000000..793fc92 --- /dev/null +++ b/docker-publish/action.yml @@ -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 = /Dockerfile. + default: "" + image-suffix: + description: Appended to the derived image name //, 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 diff --git a/forgejo-release/action.yml b/forgejo-release/action.yml new file mode 100644 index 0000000..74fc874 --- /dev/null +++ b/forgejo-release/action.yml @@ -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