ci: always build main branch into docker with main label

purpose is to let users test before waiting any new tag

note: build will occurs only on AtalayaLabs repos or if ENABLE_DOCKER_PUBLISH is true
This commit is contained in:
Edouard Vanbelle
2026-08-24 21:10:57 +02:00
parent ba5f6b750f
commit a99a6806b5
4 changed files with 562 additions and 28 deletions
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# =============================================================
# Compute Docker channel + version + tag set for the
# `docker-publish` workflow. Extracted from the workflow so the
# logic can be unit-tested via `scripts/test-docker-publish-tags.sh`
# without needing to dispatch the workflow itself.
#
# The workflow's meta step invokes this via `bash scripts/
# compute-docker-tags.sh` with the GITHUB_* env vars set; the
# same call form works from a local shell for smoke checks
# ("what would we publish if I tag v0.8.8 tomorrow?").
#
# Inputs (env vars — missing required inputs exit non-zero):
# EVENT_NAME workflow_dispatch | release | push
# GITHUB_REF refs/heads/main | refs/tags/vX.Y.Z | ...
# (required for the `push` event)
# DISPATCH_VERSION workflow_dispatch only, e.g. v0.5.3 or 0.5.3
# RELEASE_TAG release event only, e.g. v0.8.7
# REGISTRY_IMAGE Docker Hub image (e.g. diocrafts/oxicloud)
# GHCR_REGISTRY_IMAGE GHCR image (e.g. ghcr.io/atalayalabs/oxicloud)
# SKIP_DOCKERHUB optional. When "true", omits Docker Hub tags
# from the output — used by forks whose
# DOCKERHUB_TOKEN secret isn't configured. The
# workflow only pushes to GHCR (which needs no
# external secret; auth via GITHUB_TOKEN).
#
# Outputs:
# Always writes `version=<v>`, `channel=<c>`, and a `tags:` block
# to stdout — visible in workflow logs and captured by the test
# harness for diff-based assertions.
#
# When `GITHUB_OUTPUT` is set (inside a GHA `run:` step), also
# emits the same values via GHA's `>> $GITHUB_OUTPUT` convention
# so subsequent steps can reference `${{ steps.meta.outputs.tags }}`.
#
# Channel semantics (mirrors the workflow's tag policy):
# release — tag push / release event / manual dispatch:
# publish `:<version>` AND move `:latest`.
# main — push to `main` branch: publish `:main` (mutable
# tip) only. Never touches `:latest`, never emits a
# per-commit `:main-<sha>` (would balloon the
# registry across every merge).
# =============================================================
set -euo pipefail
: "${EVENT_NAME:?EVENT_NAME required}"
: "${REGISTRY_IMAGE:?REGISTRY_IMAGE required}"
: "${GHCR_REGISTRY_IMAGE:?GHCR_REGISTRY_IMAGE required}"
# Both GHCR and Docker Hub reject mixed-case namespace / image names
# ("repository name must be lowercase"). `${{ github.repository_owner
# }}` in the workflow inserts the GitHub username verbatim, and GitHub
# expression syntax has no `lower()` function. So we normalise here
# — the workflow keeps its declarative `env:` block, the script owns
# the case-safety contract, and tests cover it (see
# `test-docker-publish-tags.sh` for mixed-case cases).
REGISTRY_IMAGE=$(echo "$REGISTRY_IMAGE" | tr '[:upper:]' '[:lower:]')
GHCR_REGISTRY_IMAGE=$(echo "$GHCR_REGISTRY_IMAGE" | tr '[:upper:]' '[:lower:]')
# Docker Hub is optional — a fork without DOCKERHUB_TOKEN configured
# still publishes to GHCR (its own namespace, auth via GITHUB_TOKEN)
# but skips Docker Hub cleanly. The workflow sets this to "true" when
# `secrets.DOCKERHUB_TOKEN` is empty; the tag set below omits DH
# entries in that case.
SKIP_DOCKERHUB="${SKIP_DOCKERHUB:-false}"
case "$EVENT_NAME" in
workflow_dispatch)
: "${DISPATCH_VERSION:?DISPATCH_VERSION required for workflow_dispatch}"
VERSION="${DISPATCH_VERSION#v}"
CHANNEL="release"
;;
release)
: "${RELEASE_TAG:?RELEASE_TAG required for release event}"
VERSION="${RELEASE_TAG#v}"
CHANNEL="release"
;;
push)
: "${GITHUB_REF:?GITHUB_REF required for push event}"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
VERSION="main"
CHANNEL="main"
else
# Tag push — strip refs/tags/ prefix and leading `v`.
VERSION="${GITHUB_REF#refs/tags/}"
VERSION="${VERSION#v}"
CHANNEL="release"
fi
;;
*)
echo "compute-docker-tags: unsupported EVENT_NAME: $EVENT_NAME" >&2
exit 1
;;
esac
# Assemble the multi-line tag set. Format matches what the
# `docker/build-push-action` `tags:` input consumes — one tag
# per line, whitespace ignored between lines. Docker Hub entries
# omitted entirely when SKIP_DOCKERHUB=true — the build-push-action
# just doesn't see the tags, so no auth is attempted for them.
if [ "$CHANNEL" = "main" ]; then
if [ "$SKIP_DOCKERHUB" = "true" ]; then
TAGS="${GHCR_REGISTRY_IMAGE}:main"
else
TAGS="${REGISTRY_IMAGE}:main
${GHCR_REGISTRY_IMAGE}:main"
fi
else
if [ "$SKIP_DOCKERHUB" = "true" ]; then
TAGS="${GHCR_REGISTRY_IMAGE}:${VERSION}
${GHCR_REGISTRY_IMAGE}:latest"
else
TAGS="${REGISTRY_IMAGE}:${VERSION}
${REGISTRY_IMAGE}:latest
${GHCR_REGISTRY_IMAGE}:${VERSION}
${GHCR_REGISTRY_IMAGE}:latest"
fi
fi
# Emit to $GITHUB_OUTPUT when running under GHA — subsequent
# workflow steps read via `${{ steps.meta.outputs.tags }}`.
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "version=$VERSION"
echo "channel=$CHANNEL"
echo "tags<<EOF"
echo "$TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
fi
# Also emit VERSION + CHANNEL + SKIP_DOCKERHUB to $GITHUB_ENV —
# later steps (`Verify published image`, DockerHub-gated conditionals)
# read these directly. Keeps the step-scoped env aligned with the
# steps.meta.outputs.* set for consumers that prefer one or the other.
#
# Also OVERRIDE the workflow-level REGISTRY_IMAGE / GHCR_REGISTRY_IMAGE
# env vars with the lowercased forms. Without this, the verify step
# would read the workflow-declared mixed-case value from
# `${{ github.repository_owner }}` (e.g. `ghcr.io/EdouardVanbelle/
# oxicloud`) and `docker pull` would reject it — despite the tags
# themselves being lowercased in the actual push. Step-level env
# additions take precedence over workflow-level for subsequent steps.
if [ -n "${GITHUB_ENV:-}" ]; then
{
echo "VERSION=$VERSION"
echo "CHANNEL=$CHANNEL"
echo "SKIP_DOCKERHUB=$SKIP_DOCKERHUB"
echo "REGISTRY_IMAGE=$REGISTRY_IMAGE"
echo "GHCR_REGISTRY_IMAGE=$GHCR_REGISTRY_IMAGE"
} >> "$GITHUB_ENV"
fi
# Always echo to stdout — visible in workflow logs (useful for
# dry-run verification, when the push step is skipped) and
# consumed by the test harness for equality checks.
echo "version=$VERSION"
echo "channel=$CHANNEL"
echo "tags:"
echo "$TAGS" | sed 's/^/ /'
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# =============================================================
# Unit tests for `scripts/compute-docker-tags.sh`.
#
# Exercises every trigger channel the docker-publish workflow
# supports, plus the invalid-input path. Runs standalone in
# under a second — cheap regression check to lock the tag-set
# contract before pushing changes to `.github/workflows/
# docker-publish.yml`.
#
# Run:
# bash scripts/test-docker-publish-tags.sh
# =============================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="$SCRIPT_DIR/compute-docker-tags.sh"
if [ ! -f "$SCRIPT" ]; then
echo "compute-docker-tags.sh not found at $SCRIPT" >&2
exit 2
fi
REGISTRY_IMAGE=diocrafts/oxicloud
GHCR_REGISTRY_IMAGE=ghcr.io/atalayalabs/oxicloud
pass=0
fail=0
# Runs the script with the given env, compares stdout against expected.
# `env "$@" bash ...` passes the env vars only for this invocation so
# leftover state from a prior case can't leak across.
expect() {
local name="$1" expected="$2"
shift 2
local actual rc
actual=$(env -i \
REGISTRY_IMAGE="$REGISTRY_IMAGE" \
GHCR_REGISTRY_IMAGE="$GHCR_REGISTRY_IMAGE" \
PATH="/usr/bin:/bin" \
"$@" \
bash "$SCRIPT" 2>&1)
rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: $name — script exited $rc:"
echo "$actual" | sed 's/^/ /'
fail=$((fail + 1))
return
fi
if [ "$actual" = "$expected" ]; then
echo "PASS: $name"
pass=$((pass + 1))
else
echo "FAIL: $name"
echo " expected:"
echo "$expected" | sed 's/^/ /'
echo " actual:"
echo "$actual" | sed 's/^/ /'
fail=$((fail + 1))
fi
}
expect_fail() {
local name="$1"
shift
if env -i \
REGISTRY_IMAGE="$REGISTRY_IMAGE" \
GHCR_REGISTRY_IMAGE="$GHCR_REGISTRY_IMAGE" \
PATH="/usr/bin:/bin" \
"$@" \
bash "$SCRIPT" >/dev/null 2>&1
then
echo "FAIL: $name (script should have exited non-zero)"
fail=$((fail + 1))
else
echo "PASS: $name"
pass=$((pass + 1))
fi
}
# ── Happy paths ─────────────────────────────────────────────────
expect "push to main → :main only, no :latest" \
"version=main
channel=main
tags:
diocrafts/oxicloud:main
ghcr.io/atalayalabs/oxicloud:main" \
EVENT_NAME=push GITHUB_REF=refs/heads/main
expect "push of version tag → :<v> + :latest" \
"version=0.8.7
channel=release
tags:
diocrafts/oxicloud:0.8.7
diocrafts/oxicloud:latest
ghcr.io/atalayalabs/oxicloud:0.8.7
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=push GITHUB_REF=refs/tags/v0.8.7
expect "release event → :<v> + :latest" \
"version=0.9.0
channel=release
tags:
diocrafts/oxicloud:0.9.0
diocrafts/oxicloud:latest
ghcr.io/atalayalabs/oxicloud:0.9.0
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=release RELEASE_TAG=v0.9.0
expect "workflow_dispatch with 'v' prefix" \
"version=1.0.0
channel=release
tags:
diocrafts/oxicloud:1.0.0
diocrafts/oxicloud:latest
ghcr.io/atalayalabs/oxicloud:1.0.0
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=workflow_dispatch DISPATCH_VERSION=v1.0.0
expect "workflow_dispatch without 'v' prefix (permissive)" \
"version=1.0.0
channel=release
tags:
diocrafts/oxicloud:1.0.0
diocrafts/oxicloud:latest
ghcr.io/atalayalabs/oxicloud:1.0.0
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=workflow_dispatch DISPATCH_VERSION=1.0.0
expect "release with pre-release version" \
"version=0.9.0-rc1
channel=release
tags:
diocrafts/oxicloud:0.9.0-rc1
diocrafts/oxicloud:latest
ghcr.io/atalayalabs/oxicloud:0.9.0-rc1
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=release RELEASE_TAG=v0.9.0-rc1
# ── SKIP_DOCKERHUB path (forks without DOCKERHUB_TOKEN) ─────────
expect "push to main + SKIP_DOCKERHUB → GHCR only" \
"version=main
channel=main
tags:
ghcr.io/atalayalabs/oxicloud:main" \
EVENT_NAME=push GITHUB_REF=refs/heads/main SKIP_DOCKERHUB=true
expect "release + SKIP_DOCKERHUB → GHCR :<v> + :latest only" \
"version=0.9.0
channel=release
tags:
ghcr.io/atalayalabs/oxicloud:0.9.0
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=release RELEASE_TAG=v0.9.0 SKIP_DOCKERHUB=true
expect "dispatch + SKIP_DOCKERHUB → GHCR :<v> + :latest only" \
"version=1.0.0
channel=release
tags:
ghcr.io/atalayalabs/oxicloud:1.0.0
ghcr.io/atalayalabs/oxicloud:latest" \
EVENT_NAME=workflow_dispatch DISPATCH_VERSION=v1.0.0 SKIP_DOCKERHUB=true
expect "explicit SKIP_DOCKERHUB=false behaves like default (both registries)" \
"version=main
channel=main
tags:
diocrafts/oxicloud:main
ghcr.io/atalayalabs/oxicloud:main" \
EVENT_NAME=push GITHUB_REF=refs/heads/main SKIP_DOCKERHUB=false
# ── Case-safety — GHCR / DH reject mixed-case names ─────────────
#
# Regression pin: `${{ github.repository_owner }}` inserts a
# GitHub username verbatim, which is often mixed-case
# (e.g. EdouardVanbelle). The registries reject that with
# "repository name must be lowercase". The script normalises
# both inputs; these cases assert it.
# Local override so we can pass a mixed-case owner without touching
# the harness's defaults on other cases.
_orig_ghcr="$GHCR_REGISTRY_IMAGE"
_orig_dh="$REGISTRY_IMAGE"
GHCR_REGISTRY_IMAGE=ghcr.io/EdouardVanbelle/OxiCloud \
REGISTRY_IMAGE=ghcr.io/EdouardVanbelle/OxiCloud \
expect "mixed-case owner and image lowercased in tags" \
"version=main
channel=main
tags:
ghcr.io/edouardvanbelle/oxicloud:main" \
EVENT_NAME=push GITHUB_REF=refs/heads/main \
REGISTRY_IMAGE=DioCrafts/OxiCloud \
GHCR_REGISTRY_IMAGE=ghcr.io/EdouardVanbelle/OxiCloud \
SKIP_DOCKERHUB=true
expect "release with mixed-case DH namespace lowercased" \
"version=0.8.7
channel=release
tags:
diocrafts/oxicloud:0.8.7
diocrafts/oxicloud:latest
ghcr.io/edouardvanbelle/oxicloud:0.8.7
ghcr.io/edouardvanbelle/oxicloud:latest" \
EVENT_NAME=release RELEASE_TAG=v0.8.7 \
REGISTRY_IMAGE=DioCrafts/OxiCloud \
GHCR_REGISTRY_IMAGE=ghcr.io/EdouardVanbelle/OxiCloud
REGISTRY_IMAGE="$_orig_dh"
GHCR_REGISTRY_IMAGE="$_orig_ghcr"
unset _orig_dh _orig_ghcr
# ── Error paths ─────────────────────────────────────────────────
expect_fail "unknown event rejected" \
EVENT_NAME=cron GITHUB_REF=refs/heads/main
expect_fail "push without GITHUB_REF rejected" \
EVENT_NAME=push
expect_fail "release without RELEASE_TAG rejected" \
EVENT_NAME=release
expect_fail "dispatch without DISPATCH_VERSION rejected" \
EVENT_NAME=workflow_dispatch
# ── Report ──────────────────────────────────────────────────────
echo ""
echo "─────────────────────────"
echo "Passed: $pass Failed: $fail"
[ "$fail" -eq 0 ]