diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1bd6e185..7e38a8a3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,7 +1,30 @@ -name: "Docker Hub & GHCR Release" +name: "Docker Publish (release, main, dry-run)" + +# Per-run title shown in the Actions tab list — makes it obvious at +# a glance which channel a given run served and (for dispatched +# runs) whether it was a dry-run. Without this, GitHub falls back +# to the commit subject, which is uninformative when multiple +# workflows fire on the same commit. +# +# Falls back to `github.ref_name` for push / release events (which +# don't carry `inputs.*`), and stitches "[DRY-RUN]" onto the +# dispatched cases where `inputs.dry_run` is checked. +run-name: >- + Docker Publish + ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '[DRY-RUN]' || '' }} + — ${{ github.event.inputs.version || github.ref_name }} on: + # Every merge to `main` republishes the mutable `:main` tag so users + # tracking the tip of development can pull the freshest image + # without waiting for a release. `:latest` is DELIBERATELY not + # touched on this trigger — it stays pointed at the last released + # version. Provenance for a specific `:main` pull is queryable via + # `docker inspect` (org.opencontainers.image.revision label carries + # the SHA). push: + branches: + - "main" tags: - "v*" release: @@ -12,14 +35,44 @@ on: version: description: 'Version tag to publish (e.g. v0.5.3)' required: true + dry_run: + description: 'Dry run — build only, skip push + verify. Prints the tag set that WOULD be pushed. Use to smoke-test workflow edits without touching the registry.' + required: false + type: boolean + default: false env: + # Docker Hub image name is fixed to the canonical namespace — forks + # that opt in to publishing typically also override this with their + # own DockerHub account name (see the fork guide in + # docs/plan/docker-publish.md, if/when documented). REGISTRY_IMAGE: diocrafts/oxicloud - GHCR_REGISTRY_IMAGE: ghcr.io/atalayalabs/oxicloud + # GHCR image name follows the repo owner — canonical repo publishes + # to `ghcr.io/atalayalabs/oxicloud`; a fork opting in via + # `vars.ENABLE_DOCKER_PUBLISH=true` publishes to its own owner's + # namespace with zero config edits. + GHCR_REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/oxicloud + +# Cancel superseded `:main` builds if commits land in quick succession +# — only the newest one matters, and having two racing builds pushing +# to the same mutable tag is a coin-toss on which one wins. Release-tag +# and manual-dispatch builds never cancel: each release is unique and +# irreversible; every one must publish. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} jobs: - # Run tests before publishing + # Run tests before publishing. + # + # SKIPPED on push-to-`main`: the same test matrix already ran on the + # PR that produced this commit (branch protection ensures PRs pass + # CI before merge). Re-running here would double the CI cost per + # merge for zero signal. Release-tag / manual-dispatch builds still + # test — they're explicit "shipping this" moments where + # belt-and-suspenders matters. test: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/heads/') name: Pre-publish Tests runs-on: ubuntu-latest services: @@ -60,30 +113,64 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 needs: test + # Publish gate — TWO conditions must hold: + # + # 1. The `test` job succeeded (or was skipped for push-to-main — + # branch protection ensures PR CI already ran; see the test + # job's `if:`). `always()` unblocks the `needs:` when test is + # skipped; the result check still blocks on real failures. + # + # 2. Publishing is enabled for THIS repo. Canonical + # `AtalayaLabs/OxiCloud` always publishes. Forks stay quiet by + # default (no accidental GHCR packages / wasted CI minutes when + # someone forks just to read code); a fork owner who wants to + # test-publish sets `ENABLE_DOCKER_PUBLISH=true` under + # Settings → Secrets and variables → Actions → Variables. + if: | + always() && + (needs.test.result == 'success' || needs.test.result == 'skipped') && + (github.repository == 'AtalayaLabs/OxiCloud' || vars.ENABLE_DOCKER_PUBLISH == 'true') permissions: contents: read packages: write + # Job-level env — `secrets` context is legal here but NOT in + # step-level `if:` conditions. Precomputing the "is DH configured" + # signal as an env var lets downstream steps gate cleanly via + # `env.HAS_DOCKERHUB_TOKEN == 'true'` — see the DockerHub login + # step below. + env: + HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }} steps: - name: Checkout uses: actions/checkout@v4 with: - # Build the exact tag behind the published release or manual dispatch. + # Build the exact tag behind the published release or manual + # dispatch. On push-to-main, `github.ref` resolves to + # `refs/heads/main` and this checks out the freshly-merged + # commit — exactly what we want to publish as `:main`. ref: ${{ github.event.inputs.version || github.event.release.tag_name || github.ref }} - - name: Set version tag - id: version - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" - elif [ "${{ github.event_name }}" == "release" ]; then - VERSION="${{ github.event.release.tag_name }}" - else - VERSION="${GITHUB_REF#refs/tags/}" - fi - # Strip leading 'v' if present for Docker tag - VERSION="${VERSION#v}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Resolve channel + version + tags + id: meta + # Delegates to `scripts/compute-docker-tags.sh` — logic is + # unit-tested via `scripts/test-docker-publish-tags.sh` so + # any change to the tag policy gets caught before hitting a + # runner. The script emits `version`, `channel`, and `tags` + # to $GITHUB_OUTPUT (for later `steps.meta.outputs.*`), + # plus `VERSION` / `CHANNEL` / `SKIP_DOCKERHUB` to + # $GITHUB_ENV (for later steps that read env directly), plus + # a human-readable trailer to stdout for the run log — + # useful in dry-run mode where the tag set is the deliverable. + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + DISPATCH_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + # Empty DOCKERHUB_TOKEN → skip DH tags cleanly (forks that + # opted in via ENABLE_DOCKER_PUBLISH but didn't set up + # DockerHub still get their GHCR image published). + SKIP_DOCKERHUB: ${{ secrets.DOCKERHUB_TOKEN == '' && 'true' || 'false' }} + run: bash "$GITHUB_WORKSPACE/scripts/compute-docker-tags.sh" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -92,6 +179,19 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to DockerHub + # Skipped cleanly when DOCKERHUB_TOKEN isn't configured on + # this repo — the meta step's SKIP_DOCKERHUB env drops DH + # tags from the push set in that case, so we don't need + # DH auth at all. Canonical repo always has the secret and + # always publishes; forks are opt-in via ENABLE_DOCKER_PUBLISH + # AND can further opt in / out of DH separately by + # adding / omitting DOCKERHUB_TOKEN. + # + # `secrets` context is not available in step-level `if:` + # conditions — we read it via the job-level env var + # `HAS_DOCKERHUB_TOKEN` computed above (which CAN reference + # secrets since it lives in `env:`, not `if:`). + if: env.HAS_DOCKERHUB_TOKEN == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -109,12 +209,17 @@ jobs: with: context: . platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} - ${{ env.REGISTRY_IMAGE }}:latest - ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} - ${{ env.GHCR_REGISTRY_IMAGE }}:latest + # `push` flips to `false` for a dry-run — the multi-arch + # build still runs (catches Dockerfile regressions), but + # nothing hits the registry. Only reachable via + # `workflow_dispatch` with `dry_run: true`. Real push + # events (release, branch push) always publish. + push: ${{ github.event.inputs.dry_run != 'true' }} + # Tag set computed in the meta step above — release channel + # publishes `:` + `:latest`; main channel publishes + # just `:main`. Emitted to the build log either way so the + # dry-run mode surfaces "what would ship" in plain sight. + tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max # GitHub Actions env piped through so build.rs stamps @@ -129,7 +234,36 @@ jobs: GITHUB_HEAD_REF=${{ github.head_ref }} - name: Verify published image + # Skipped on dry-run — nothing was pushed to pull back. + # Verifies GHCR unconditionally (always pushed), then adds a + # Docker Hub pull when the DH branch actually ran. Simpler + # than a matrix — the two registries share the same content + # (same multi-arch manifest), so one pull confirms the build + # + push worked; the other is just a "did we auth to both" + # sanity check. + if: github.event.inputs.dry_run != 'true' run: | - docker pull ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} - docker image inspect ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} - echo "✅ Image ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} published successfully" + echo "─── Verify GHCR ───" + docker pull ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} + docker image inspect ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} > /dev/null + echo "✅ ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} published" + if [ "${{ env.SKIP_DOCKERHUB }}" != "true" ]; then + echo "─── Verify Docker Hub ───" + docker pull ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} + docker image inspect ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} > /dev/null + echo "✅ ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} published" + else + echo "ℹ️ Skipped Docker Hub verification (DOCKERHUB_TOKEN not set on this repo)" + fi + + - name: Dry-run summary + # Only surfaces in dry-run mode. Mirrors the "Verify" step's + # role — gives the operator running the dry-run a clear + # closing message with the exact tag set the workflow would + # have pushed. The meta step already logged it, this step + # just makes it prominent at the bottom of the run. + if: github.event.inputs.dry_run == 'true' + run: | + echo "🔍 DRY RUN — image built + tagged but NOT pushed." + echo "Would have published:" + echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ /' diff --git a/justfile b/justfile index ad8557c7..c958cc61 100644 --- a/justfile +++ b/justfile @@ -377,6 +377,10 @@ load-baseline: load-seed: cargo run --bin load-seed -- --depth 5 --fanout 4 --files-per-leaf 3 +# Unit-test the Docker publish tag-policy script (sub-second). +test-docker-tags: + @bash scripts/test-docker-publish-tags.sh + # Check and test everything # recommanded before pull request -pre-pull-request: check fe-check audit check-migrations test test-integration fe-test build api-test fe-build-e2e front-test +pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build api-test fe-build-e2e front-test diff --git a/scripts/compute-docker-tags.sh b/scripts/compute-docker-tags.sh new file mode 100755 index 00000000..765ef171 --- /dev/null +++ b/scripts/compute-docker-tags.sh @@ -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=`, `channel=`, 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 `:` AND move `:latest`. +# main — push to `main` branch: publish `:main` (mutable +# tip) only. Never touches `:latest`, never emits a +# per-commit `:main-` (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<> "$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/^/ /' diff --git a/scripts/test-docker-publish-tags.sh b/scripts/test-docker-publish-tags.sh new file mode 100755 index 00000000..ad38bacf --- /dev/null +++ b/scripts/test-docker-publish-tags.sh @@ -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 → : + :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 → : + :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 : + :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 : + :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 ]