chore: Update @types/node to 20.19.43 #85

Merged
darkhelm merged 2 commits from renovate/node-20.19.x-lockfile into main 2026-07-20 14:18:10 -04:00
21 changed files with 2613 additions and 1493 deletions
Showing only changes of commit 3f8e32db4f - Show all commits
+1008 -725
View File
File diff suppressed because it is too large Load Diff
+71 -13
View File
@@ -26,6 +26,8 @@ jobs:
RENOVATE_IMAGE_FALLBACK: ghcr.io/renovatebot/renovate:41
GITEA_REGISTRY_HOST: kankali.darkhelm.lan
GITEA_REGISTRY_IP: 10.18.75.2
PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }}
REGISTRY_USER: ${{ github.actor }}
run: |
set -euo pipefail
@@ -60,17 +62,78 @@ jobs:
echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts
fi
if [ -n "${PACKAGE_ACCESS_TOKEN:-}" ] && [ -n "${REGISTRY_USER:-}" ]; then
echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY_HOST}:3001" -u "${REGISTRY_USER}" --password-stdin >/dev/null || true
fi
extract_remote_digest_buildx() {
image_ref="$1"
digest="$(docker buildx imagetools inspect "${image_ref}" --format '{{json .Manifest.Digest}}' 2>/dev/null || true)"
digest="${digest//\"/}"
digest="${digest//$'\n'/}"
digest="${digest//$'\r'/}"
digest="${digest// /}"
if [[ "${digest}" == sha256:* ]]; then
printf '%s\n' "${digest}"
return 0
fi
return 1
}
extract_remote_digest_manifest() {
image_ref="$1"
digest="$(docker manifest inspect "${image_ref}" 2>/dev/null | sed -n 's/.*"digest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 || true)"
if [[ "${digest}" == sha256:* ]]; then
printf '%s\n' "${digest}"
return 0
fi
return 1
}
remote_digest() {
image_ref="$1"
if [[ "${image_ref}" == *@sha256:* ]]; then
printf '%s\n' "${image_ref##*@}"
return 0
fi
extract_remote_digest_buildx "${image_ref}" || extract_remote_digest_manifest "${image_ref}"
}
local_digest_matches() {
image_ref="$1"
digest="$2"
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${image_ref}" 2>/dev/null | grep -q "@${digest}$"
}
resolve_candidate() {
image_ref="$1"
if docker image inspect "${image_ref}" >/dev/null 2>&1; then
if digest="$(remote_digest "${image_ref}")"; then
if local_digest_matches "${image_ref}" "${digest}"; then
echo "Using cached current image: ${image_ref}" >&2
printf '%s\n' "${image_ref}"
return 0
fi
echo "Cached image is stale for ${image_ref}; refreshing" >&2
else
echo "Remote digest unavailable for ${image_ref}; attempting refresh pull" >&2
fi
fi
if retry_cmd 3 15 docker pull "${image_ref}" >/dev/null; then
printf '%s\n' "${image_ref}"
return 0
fi
return 1
}
pick_renovate_image() {
for candidate in "${RENOVATE_IMAGE_PRIMARY}" "${RENOVATE_IMAGE_FALLBACK}"; do
echo "Trying Renovate image candidate: ${candidate}" >&2
if docker image inspect "${candidate}" >/dev/null 2>&1; then
echo "Using cached Renovate image: ${candidate}" >&2
printf '%s\n' "${candidate}"
return 0
fi
if retry_cmd 3 15 docker pull "${candidate}" >/dev/null; then
if resolve_candidate "${candidate}" >/dev/null; then
printf '%s\n' "${candidate}"
return 0
fi
@@ -94,11 +157,6 @@ jobs:
echo "RENOVATE_IMAGE=${RENOVATE_IMAGE}" >> "$GITHUB_ENV"
- name: Configure Renovate for Gitea
run: |
echo "=== Configuring Renovate for Gitea ==="
echo "✓ Renovate runtime configuration will be passed through environment and CLI flags"
- name: Run Renovate
env:
# Prefer dedicated Renovate token, then fall back to existing CI tokens.
@@ -490,4 +548,4 @@ jobs:
echo "Check the Dependency Dashboard issue in your repository for detailed results:"
echo "https://dogar.darkhelm.org/DarkHelm.org/plex-playlist/issues"
echo ""
echo "Next scheduled run: Next Monday at 8 AM UTC"
echo "Next scheduled run: Next weekday at 8 AM UTC"
+43 -35
View File
@@ -1,5 +1,5 @@
# CICD Base Setup - System dependencies and language runtimes only
ARG PLAYWRIGHT_BROWSERS_IMAGE=mcr.microsoft.com/playwright:v1.56.1-jammy
ARG PLAYWRIGHT_BROWSERS_IMAGE=mcr.microsoft.com/playwright:v1.61.1-jammy
FROM ${PLAYWRIGHT_BROWSERS_IMAGE} AS playwright-browsers
FROM ubuntu:22.04
@@ -19,43 +19,47 @@ ENV TZ=America/New_York
# Configure timezone
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install apt-fast with proper GPG handling
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
for i in 1 2 3; do \
echo "Attempt $i: Updating package lists..." && \
apt-get update && break || \
(echo "Update attempt $i failed, retrying..." && sleep 10); \
done && \
apt-get install -y \
software-properties-common \
gnupg \
ca-certificates \
curl \
wget \
&& for i in 1 2 3; do \
echo "Attempt $i: Adding apt-fast PPA..." && \
add-apt-repository -y ppa:apt-fast/stable && \
apt-get update && \
apt-get install -y apt-fast && \
break || \
(echo "apt-fast installation attempt $i failed, retrying..." && sleep 10); \
done \
&& rm -rf /var/lib/apt/lists/*
# Configure apt-fast to use apt (not apt-get) with optimized settings
RUN echo 'apt-fast apt-fast/maxdownloads string 10' | debconf-set-selections && \
echo 'apt-fast apt-fast/dlflag boolean true' | debconf-set-selections && \
echo 'apt-fast apt-fast/aptmanager string apt' | debconf-set-selections
# Configure apt timeouts and retries
RUN echo 'Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
echo 'Acquire::http::Timeout "60";' >> /etc/apt/apt.conf.d/80retries && \
echo 'Acquire::https::Timeout "60";' >> /etc/apt/apt.conf.d/80retries && \
echo 'Acquire::ftp::Timeout "60";' >> /etc/apt/apt.conf.d/80retries
# Install system dependencies using apt-fast
RUN apt-fast update && apt-fast install -y \
# Bootstrap certificates over HTTP, then enforce HTTPS for all remaining package operations.
RUN set -eux; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
find /etc/apt -type f \( -name 'sources.list' -o -name '*.sources' -o -name '*.list' \) -print0 \
| xargs -0 sed -i 's|https://ports.ubuntu.com/ubuntu-ports|http://ports.ubuntu.com/ubuntu-ports|g; s|https://archive.ubuntu.com/ubuntu|http://archive.ubuntu.com/ubuntu|g; s|https://security.ubuntu.com/ubuntu|http://security.ubuntu.com/ubuntu|g'; \
for i in 1 2 3; do \
echo "Attempt $i: Bootstrapping CA certificates..."; \
if apt-get update && apt-get install -y --no-install-recommends ca-certificates; then \
break; \
fi; \
if [ "$i" -lt 3 ]; then \
echo "Bootstrap attempt $i failed, retrying..."; \
sleep 10; \
else \
exit 1; \
fi; \
done; \
update-ca-certificates; \
find /etc/apt -type f \( -name 'sources.list' -o -name '*.sources' -o -name '*.list' \) -print0 \
| xargs -0 sed -i 's|http://ports.ubuntu.com/ubuntu-ports|https://ports.ubuntu.com/ubuntu-ports|g; s|http://archive.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g; s|http://security.ubuntu.com/ubuntu|https://security.ubuntu.com/ubuntu|g'; \
rm -rf /var/lib/apt/lists/*; \
for i in 1 2 3; do \
echo "Attempt $i: Updating package lists over HTTPS..."; \
if apt-get update; then \
break; \
fi; \
if [ "$i" -lt 3 ]; then \
echo "HTTPS update attempt $i failed, retrying..."; \
sleep 10; \
else \
exit 1; \
fi; \
done; \
apt-get install -y --no-install-recommends \
git \
curl \
ca-certificates \
@@ -75,12 +79,15 @@ RUN apt-fast update && apt-fast install -y \
libxkbcommon0 \
libasound2 \
tzdata \
gnupg \
wget \
&& rm -rf /var/lib/apt/lists/*
# Install Python 3.14 with retry and fallback mechanisms
RUN for i in 1 2 3; do \
echo "Attempt $i: Adding deadsnakes PPA..." && \
add-apt-repository -y ppa:deadsnakes/ppa && \
find /etc/apt -type f \( -name 'sources.list' -o -name '*.sources' -o -name '*.list' \) -print0 | xargs -0 sed -i 's|http://|https://|g' && \
apt-get update && \
break || \
(echo "Attempt $i failed, retrying in 10s..." && sleep 10); \
@@ -88,7 +95,7 @@ RUN for i in 1 2 3; do \
RUN for i in 1 2 3; do \
echo "Attempt $i: Installing Python 3.14..." && \
timeout 300 apt-fast install -y \
timeout 300 apt-get install -y --no-install-recommends \
python3.14 \
python3.14-venv \
python3.14-dev && \
@@ -102,8 +109,9 @@ RUN for i in 1 2 3; do \
echo "Attempt $i: Installing Node.js 24..." && \
curl -fsSL --connect-timeout 30 --max-time 300 \
https://deb.nodesource.com/setup_24.x | bash - && \
apt-fast update && \
timeout 300 apt-fast install -y nodejs && \
find /etc/apt -type f \( -name 'sources.list' -o -name '*.sources' -o -name '*.list' \) -print0 | xargs -0 sed -i 's|http://|https://|g' && \
apt-get update && \
timeout 300 apt-get install -y --no-install-recommends nodejs && \
break || \
(echo "Attempt $i failed, retrying in 15s..." && sleep 15); \
done && \
+1 -1
View File
@@ -1,4 +1,4 @@
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright:v1.56.1-jammy
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright:v1.61.1-jammy
FROM ${PLAYWRIGHT_BASE_IMAGE}
WORKDIR /workspace/frontend
+1 -2
View File
@@ -19,8 +19,7 @@ RUN set -eux; \
done; \
return 1; \
}; \
corepack enable; \
retry 5 corepack prepare yarn@4.10.3 --activate; \
retry 5 npm install -g --force @yarnpkg/cli-dist@4.10.3; \
retry 5 yarn install --immutable
FROM deps AS build
+1 -1
View File
@@ -36,7 +36,7 @@ classifiers = [
]
dependencies = [
"fastapi==0.139.0",
"sqlalchemy==2.0.44",
"sqlalchemy==2.0.51",
"psycopg[binary]==3.2.12",
"uvicorn==0.51.0"
]
+2 -2
View File
@@ -21,10 +21,10 @@ from backend.database import (
)
REQUIRED_PACKAGE_PINS: dict[str, str] = {
"fastapi": "0.120.2",
"fastapi": "0.139.0",
"psycopg": "3.2.12",
"sqlalchemy": "2.0.44",
"uvicorn": "0.38.0",
"uvicorn": "0.51.0",
}
+2 -2
View File
@@ -95,8 +95,8 @@ class TestAPIIntegration:
assert "current_python" in payload
assert payload["python_policy_valid"] is True
assert "required_packages" in payload
assert payload["required_packages"]["fastapi"] == "0.120.2"
assert payload["required_packages"]["uvicorn"] == "0.38.0"
assert payload["required_packages"]["fastapi"] == "0.139.0"
assert payload["required_packages"]["uvicorn"] == "0.51.0"
assert payload["package_errors"] == {}
monkeypatch.delenv("BACKEND_REQUIRED_PYTHON", raising=False)
+93 -2
View File
@@ -1,12 +1,22 @@
"""Basic tests for the backend application."""
from typing import cast
from importlib import metadata
from typing import Any, cast
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.main import app, get_api_session, read_root
from backend.main import (
app,
compatibility_check,
compatibility_status,
get_api_session,
read_root,
validate_runtime_policy,
)
def test_app_creation():
@@ -56,3 +66,84 @@ def test_typeguard_validation():
# This would fail with typeguard active, but we'll just test the happy path
# to ensure coverage of our code without breaking the test
def test_health_check_db_unavailable():
"""Health endpoint should return unavailable when DB probe fails."""
unhealthy_session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
unhealthy_session.execute = AsyncMock(
side_effect=SQLAlchemyError("database unavailable")
)
async def override_get_session():
"""Provide an unhealthy session dependency override for tests."""
yield unhealthy_session
app.dependency_overrides[get_api_session] = override_get_session
try:
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 503
assert response.json() == {
"status": "unhealthy",
"database": "disconnected",
}
finally:
app.dependency_overrides.clear()
def test_compatibility_status_success(monkeypatch: pytest.MonkeyPatch):
"""Compatibility status should be healthy when all checks match policy."""
monkeypatch.setenv("BACKEND_REQUIRED_PYTHON", "3.14")
def installed_version(package_name: str) -> str:
versions = {
"fastapi": "0.139.0",
"psycopg": "3.2.12",
"sqlalchemy": "2.0.44",
"uvicorn": "0.51.0",
}
return versions[package_name]
monkeypatch.setattr("backend.main._installed_version", installed_version)
status = cast("dict[str, Any]", compatibility_status())
assert status["ok"] is True
assert status["package_errors"] == {}
def test_compatibility_status_missing_package(monkeypatch: pytest.MonkeyPatch):
"""Compatibility status should record metadata lookup failures."""
def missing_version(_: str) -> str:
raise metadata.PackageNotFoundError("missing")
monkeypatch.setattr("backend.main._installed_version", missing_version)
status = cast("dict[str, Any]", compatibility_status())
package_checks = cast("dict[str, bool]", status["package_checks"])
package_errors = cast("dict[str, str]", status["package_errors"])
assert status["ok"] is False
assert package_checks["fastapi"] is False
assert "fastapi" in package_errors
def test_validate_runtime_policy_raises(monkeypatch: pytest.MonkeyPatch):
"""Runtime policy validation should fail when compatibility is not ok."""
def invalid_status() -> dict[str, object]:
return {"ok": False}
monkeypatch.setattr("backend.main.compatibility_status", invalid_status)
with pytest.raises(RuntimeError):
validate_runtime_policy()
def test_compatibility_check_returns_status() -> None:
"""Compatibility endpoint helper should return policy payload."""
payload = compatibility_check()
assert "ok" in payload
+645 -639
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
---
services:
database:
image: postgres:16-alpine
image: postgres:18-alpine
environment:
POSTGRES_DB: plex_playlist
POSTGRES_USER: plex_user
+1 -1
View File
@@ -1,7 +1,7 @@
---
services:
database:
image: postgres:16-alpine
image: postgres:18-alpine
environment:
POSTGRES_DB: plex_playlist
POSTGRES_USER: plex_user
+1 -1
View File
@@ -1,7 +1,7 @@
---
services:
database:
image: postgres:16-alpine
image: postgres:18-alpine
environment:
POSTGRES_DB: plex_playlist
POSTGRES_USER: plex_user
+81 -26
View File
@@ -4,6 +4,22 @@
This project uses a two-stage Docker build approach to optimize CI/CD performance by separating stable base dependencies from project-specific code and dependencies.
## Current Workflow Status (2026-07)
The source of truth is `.gitea/workflows/cicd.yaml`.
Current behavior to keep in mind:
- CICD base publication and complete CICD publication now run in one merged producer job.
- Dependency audits run in one informational lane; frontend and backend audits always execute and are non-blocking.
- Release and tester images are built in two merged producer jobs.
- Deployable backend/frontend runtime images are first published as staging artifacts.
- A dedicated promotion lane publishes release image tags only after integration and E2E lanes succeed, and only for automated `push` runs on `main`.
- If a qualifying `main` commit is untagged, CI auto-creates the next patch tag starting from a `v0.0.0` baseline.
- Release promotion publishes release-line tags (`v<major>.<minor>.0`) plus patch/build tags (`v<major>.<minor>.<patch>`, `v<major>.<minor>.<patch>-<7-char-short-sha>`).
- Registry operations include auth-realm host pinning and bounded retry logic for login/pull/push in critical lanes.
- Runtime integration checks consume tag and digest outputs and verify they resolve to the same immutable artifact before assertions.
## Architecture
### Stage 1: Base Image (`Dockerfile.cicd-base`)
@@ -43,7 +59,10 @@ This project uses a two-stage Docker build approach to optimize CI/CD performanc
- Pre-commit hook environments (leverages global pre-commit)
- Project-specific tooling verification
**Registry**: `dogar.darkhelm.org/darkhelm.org/plex-playlist/cicd:latest`
**Registry**:
- `kankali.darkhelm.lan:3001/darkhelm.org/plex-playlist-cicd:latest`
- `kankali.darkhelm.lan:3001/darkhelm.org/plex-playlist-cicd:<head_sha>`
**Rebuild Triggers**: Every CI/CD run (contains project-specific code and dependencies)
@@ -109,37 +128,64 @@ This project uses a two-stage Docker build approach to optimize CI/CD performanc
```yaml
jobs:
publish-base:
name: Build and Publish CICD Base Image
steps:
- name: Compute base hash
# Uses scripts/compute-cicd-base-hash.sh
# Hashes Dockerfile.cicd-base and .dockerignore
build_cicd:
name: Build and Push CICD Images
# computes base hash, checks registry, and publishes both CICD base and complete CICD images
- name: Build and push base image
if: needs_build == 'true'
# Only runs when the immutable base tag is missing or force rebuild is requested
# Tags with both hash and 'latest'
source-checks:
name: Source Checks
needs: build_cicd
- name: Verify published base image
# Confirms the immutable tag is visible and pullable before success
dependency-audits:
name: Dependency Audits (Informational)
needs: build_cicd
# frontend/backend audit steps are continue-on-error and backend uses if: always()
prepare-base-ref:
name: Prepare CICD Base Reference
steps:
- name: Compute immutable base ref
# Uses the same helper as the base workflow
build-release-images:
needs: [build_cicd, source-checks]
# publishes deployable-backend-staging and deployable-frontend-staging refs
setup:
name: Build and Push CICD Complete Image
needs: prepare-base-ref
steps:
- name: Build and push complete CICD image
# Always runs, inherits from cicd-base:<hash>
# Waits briefly for the immutable base tag to appear, then fails clearly if missing
# Contains project code and dependencies
build-tester-images:
needs: [build_cicd, source-checks]
production-images-complete:
needs: [build-release-images, build-tester-images]
integration-tests:
needs:
[
build_cicd,
production-images-complete,
build-release-images,
build-tester-images,
]
e2e-tests:
needs:
[
build_cicd,
production-images-complete,
build-release-images,
build-tester-images,
]
promote-release-images:
needs: [build_cicd, build-release-images, integration-tests, e2e-tests]
# retags staging images to deployable-backend/deployable-frontend with:
# - release series tag (<major>.<minor>.0)
# - exact git semver tag from HEAD (authoritative release tag)
# - release build tag (<git-semver>-<7-char-short-sha>)
# - latest
```
### Release Tagging Model
- Promotion is release-tag driven: if HEAD has no semver git tag (`v<major>.<minor>.<patch>`), promotion is skipped.
- Release identity is derived from the git tag on HEAD.
- Release series tag is normalized to `<major>.<minor>.0`.
- Each promoted release also publishes a build-distinguishing tag: `<git-semver>-<short_sha>`, where `<short_sha>` is the 7-character commit shorthand.
- Promotion emits generated release notes summarizing commit subjects since the previous `<major>.<minor>.0` release tag.
### Responsibility Split
- `.gitea/workflows/cicd.yaml` owns the full CI pipeline: base image publish,
@@ -259,6 +305,15 @@ RUN export NODE_OPTIONS="--max-old-space-size=1024" && \
- Fix: run or rerun the `CICD Base Image` workflow, or wait for it to finish when a PR changes base inputs
- Design note: main CI intentionally fails instead of rebuilding the base locally
### Registry Auth Realm Timeout
- Symptom: `Client.Timeout exceeded while awaiting headers` when docker push/pull calls token endpoint
- Cause: registry challenge realm host is not consistently reachable/resolved from runner
- Current workflow mitigation:
- host mapping for registry host
- challenge parsing and auth-realm host pinning
- bounded login and push/pull retries in image lanes
### Common Issues
- **SSH Key Problems**: Ensure SSH_PRIVATE_KEY secret is properly configured
+20
View File
@@ -69,6 +69,26 @@
## 🏗️ **Architecture Validation**
### **Current Release Lifecycle (2026-07)**
- Runtime deployable images are published first to staging repositories:
- `deployable-backend-staging`
- `deployable-frontend-staging`
- Runtime validation (`Runtime Black-Box Integration Tests` and `End-to-End Tests`) must pass before release tagging.
- `Promote Release Images` retags validated staging artifacts to release repositories, but only on automated `push` runs to `main`:
- `deployable-backend`
- `deployable-frontend`
- Published release tags:
- `latest`
- `v<major>.<minor>.0`
- `v<major>.<minor>.<patch>`
- `v<major>.<minor>.<patch>-<7-char-short-sha>`
- Version selection rule:
- If the qualifying `main` commit already has a semver git tag (`vX.Y.Z`), use it.
- Otherwise, auto-create and use the next patch tag from the latest semver tag in the repository.
- If no prior semver tag exists, bootstrap from `v0.0.0` and create `v0.0.1`.
- Dependency audits are informational: frontend and backend audit steps both run and do not fail the full workflow.
### **Working Component Integration**
All major components now work seamlessly together:
+149
View File
@@ -4,6 +4,155 @@
This document captures the specific optimizations, fixes, and troubleshooting approaches developed during November 2025 for the plex-playlist CI/CD pipeline. Each entry includes the problem, root cause analysis, solution implementation, and performance impact.
## Current Workflow Reference (2026-07)
The authoritative workflow is `.gitea/workflows/cicd.yaml`.
When this guide conflicts with older examples, prefer:
- current job names and dependencies in `cicd.yaml`
- current registry endpoint `kankali.darkhelm.lan:3001`
- current retry and auth-realm host pinning logic embedded in image build lanes
## High-Value Failure Signatures (Current)
### 0. Dependency audit step fails but workflow stays green
**Symptom**:
```text
frontend audit reported vulnerabilities
backend audit reported vulnerabilities
```
and overall workflow still succeeds.
**Cause**: expected behavior. `Dependency Audits (Informational)` is intentionally non-blocking and runs both frontend and backend audit steps.
**Fast check**:
1. Confirm `Dependency Audits (Informational)` ran.
2. Confirm both audit step logs are present.
**Fix**:
No CI fix required unless policy changes. Treat findings as remediation backlog items.
### 1. `docker_login_with_retry: command not found`
**Symptom**:
```text
line <n>: docker_login_with_retry: command not found
```
**Cause**: shell helper function referenced in a job step but missing in that same step's `run` block.
**Fast check**:
1. Open failing job step in `.gitea/workflows/cicd.yaml`.
2. Confirm `docker_login_with_retry()` is defined before first call in that block.
**Fix**:
Add the helper definition locally in that step block (functions do not cross step boundaries).
### 2. Registry token timeout while pushing/pulling
**Symptom**:
```text
Client.Timeout exceeded while awaiting headers
... /v2/token?...service=container_registry
```
**Cause**: runner resolves/pins registry host, but token realm host from `WWW-Authenticate` challenge is unresolved/unreachable.
**Fast check**:
1. Verify `Configure registry host resolution` step ran.
2. Confirm auth realm host pinning logic is present in failing lane.
3. Check lane-specific login/push retry helpers are active.
**Fix**:
Use `ensure_registry_auth_realm_host` + `docker_login_with_retry` + bounded `retry_registry_op` in the failing lane.
### 3. Empty downstream digest/tag outputs
**Symptom**:
```text
evaluated to '%!t(string=)'
```
**Cause**: upstream image lane failed before writing expected outputs (`*_tag_ref`, `*_digest_ref`).
**Fast check**:
1. Inspect upstream image job conclusion (`Build Frontend Main Image`, `Build Integration Tester Image`, etc.).
2. Confirm output writes (`echo key=value >> $GITHUB_OUTPUT`) execute after push and digest resolution.
**Fix**:
Repair failing upstream lane first; downstream expressions become valid once outputs are emitted.
### 4. Base image publication mismatch
**Symptom**:
```text
Required immutable base image is not available
```
**Cause**: expected hash tag not yet published or failed publication lane.
**Fix order**:
1. `Build and Push CICD Images`
2. remaining source, image, and runtime lanes
### 5. Release tags missing after tests passed
**Symptom**:
```text
staging images exist but deployable-backend/deployable-frontend tags not updated
```
**Cause**: promotion lane did not run or failed (`Promote Release Images`).
**Fast check**:
1. Confirm `Runtime Black-Box Integration Tests` and `End-to-End Tests` succeeded.
2. Confirm the workflow run was an automated `push` on `main`; promotion is skipped for PR validation and other non-main events.
3. Check `Promote Release Images` logs for registry login, tag creation, pull/tag/push failures.
4. Verify staging refs (`deployable-backend-staging`, `deployable-frontend-staging`) were emitted by `Build Release Images`.
**Fix**:
Re-run `Promote Release Images` after correcting registry/auth issues.
### 6. Unexpected release version chosen
**Symptom**:
```text
release_version differs from expected manual guess
```
**Cause**: promotion versioning follows workflow rules:
1. Promotion only runs for automated `push` events on `main`.
2. If the `main` commit already has semver tag `vX.Y.Z`, use that exact patch tag.
3. Otherwise, find the latest semver tag in the repository and auto-create the next patch tag.
4. If no semver tag exists yet, bootstrap from `v0.0.0` and create `v0.0.1`.
5. Also publish `vX.Y.0` and `vX.Y.Z-<7-char-short-sha>`.
**Fix**:
If you need an exact patch version, tag the `main` commit with semver before promotion runs; otherwise let CI assign the next patch automatically.
## Performance Optimizations
### 1. Dependency-First Build Pattern
+8
View File
@@ -11,6 +11,14 @@ automation work under epic #66.
## Scope
Lifecycle note:
- CI first builds deployable runtime artifacts in staging repositories
(`deployable-backend-staging`, `deployable-frontend-staging`).
- After integration and E2E validation pass, CI promotes those immutable
artifacts to release repositories (`deployable-backend`,
`deployable-frontend`) via tag promotion.
Included:
- Backend deployable runtime image requirements.
+51 -40
View File
@@ -17,10 +17,11 @@ This document outlines how to set up your development environment and work with
- **[Poe Task Reference](POE_TASK_REFERENCE.md)** - Complete guide to unified development tasks
- **[CI/CD Multi-Stage Build Architecture](CICD_MULTI_STAGE_BUILD.md)** - Technical details of the optimized build system
- **[CI/CD Troubleshooting](GITEA_ACTIONS_TROUBLESHOOTING.md)** - Common issues and solutions
- **[CI/CD Troubleshooting](CICD_TROUBLESHOOTING_GUIDE.md)** - Current CI lane failures and remediation paths
- **[Secure Docker CI/CD](SECURE_DOCKER_CICD.md)** - Security considerations and practices
- **[Deployable Runtime Contract](DEPLOYABLE_RUNTIME_CONTRACT.md)** - Canonical backend/frontend runtime artifact contract and exclusion rules
- **[ADR003: Deployable Runtime Image Contract Boundaries](adr/ADR003-deployable_runtime_image_contract.md)** - Decision record for deployable runtime boundaries
- **[ADR004: Registry Image Resolution and Auth Resilience Policy](adr/ADR004-registry-image-resolution-and-auth-resilience.md)** - Cross-workflow reliability policy for registry/auth/image fallback behavior
## Deployable Runtime Artifacts
@@ -209,7 +210,7 @@ git push
1. Push your feature branch to the remote repository
2. Navigate to the Gitea web interface
3. Create a Pull Request from your feature branch to `main`
4. Ensure all CI checks pass (100% green required)
4. Ensure required CI checks pass (informational audit warnings do not block merge)
5. Request review from team members
6. Merge only after approval and passing CI
@@ -388,58 +389,68 @@ pre-commit run end-of-file-fixer --all-files
### Pipeline Overview
The CI/CD pipeline uses a **multi-stage build architecture** for optimal performance:
The canonical CI workflow is `.gitea/workflows/cicd.yaml`.
- **Stage 1**: Source-level fast checks (format, lint, type-check) - **hard promotion gate**
- **Stage 2**: Build base image (system dependencies, Python, Node.js) - **cached across runs**
- **Stage 3**: Build complete image (project code and dependencies) - **rebuilt every time**
Current triggers:
Pipeline triggers:
- Push on `main` and `develop`
- Pull requests targeting `main` and `develop`
- Manual `workflow_dispatch`
- Push to any branch
- Pull requests to `main` or `develop`
Current dispatch inputs:
### Multi-Stage Build Benefits ✅ **VALIDATED SUCCESSFUL**
- `head_sha`: commit SHA to process
- `force_rebuild_base`: force base image publication
**Performance Gains**:
### Current Job Topology
- **85% build time improvement**: 3-5 minutes (down from 15-25 minutes)
- Base image cached when `Dockerfile.cicd-base` unchanged (~95% of runs)
- **100% success rate** achieved with optimized dependency management
- Raspberry Pi 4GB workers handle builds efficiently with resource optimization
The pipeline is intentionally staged so expensive image jobs run only after source checks and required gates pass:
**Architecture**:
1. Producer lane:
`Build and Push CICD Images` computes base hash, checks registry, and publishes both CICD base and complete CICD images.
2. Source + audit lanes:
`Source Checks` and `Dependency Audits (Informational)` run after producer completion. Audit failures are intentionally non-blocking so both frontend and backend audits always report.
3. Runtime image lanes:
`Build Release Images` publishes `deployable-backend-staging` and `deployable-frontend-staging`; `Build Tester Images` publishes integration/e2e tester images; then `Production Images Complete` gates downstream runtime tests.
4. Runtime validation lanes:
`Runtime Black-Box Integration Tests` and `End-to-End Tests` validate staged runtime artifacts.
5. Promotion lane:
`Promote Release Images` runs only for automated `push` events on `main`. It retags validated staging artifacts to release repos (`deployable-backend`, `deployable-frontend`) with `latest`, `v<major>.<minor>.0`, `v<major>.<minor>.<patch>`, and `v<major>.<minor>.<patch>-<7-char-short-sha>` tags. If the `main` commit is untagged, CI creates the next patch tag automatically; if no prior semver tag exists, the bootstrap baseline is `v0.0.0`.
6. Postmortem lanes:
targeted postmortem jobs run when key lanes fail to capture diagnostics even when primary jobs fail early.
- `cicd-base:latest` - System dependencies (Python 3.14, Node.js 24, build tools, pre-installed dev packages)
- `cicd:latest` - Complete environment (project code + optimized dependency installation)
### Reliability and Traceability Behavior
**Recent Optimizations** (November 2025):
Current workflow behavior includes:
- **Dependency-first build pattern** prevents cache invalidation on code changes
- **Yarn PnP state regeneration** ensures reliable frontend builds
- **Network-resilient E2E testing** with simplified Docker operations
- **Memory-optimized frontend installations** with proper swap configuration
- registry auth realm host pinning from `WWW-Authenticate` challenge when registry tokens are issued from a different host
- bounded retry logic for docker login/pull/push operations in image lanes
- digest/tag contract checks for deployable image references before runtime black-box tests
- release-note summary generation in the promotion lane (commit bullets since the previous release tag, or from the `v0.0.0` bootstrap baseline on first release)
- context hydration for image-build lanes by copying `/workspace` from the published CICD image
- runner split between `ubuntu-act` and `ubuntu-act-8gb` based on lane resource requirements
For detailed technical information, see [CI/CD Multi-Stage Build Architecture](CICD_MULTI_STAGE_BUILD.md).
For details of base/complete image build strategy, see [CI/CD Multi-Stage Build Architecture](CICD_MULTI_STAGE_BUILD.md).
For incident handling, see [CI/CD Troubleshooting](CICD_TROUBLESHOOTING_GUIDE.md).
### Pipeline Jobs
### Operator Quick Reference
All jobs run in parallel after the setup phases:
Use workflow dispatch when you need deterministic reruns on a specific commit:
1. **Source Fast Gate**:
Backend source checks (Ruff format/lint, Pyright), frontend source checks (Prettier, ESLint, TypeScript), and dispatches downstream build only on success.
```bash
# Example: rerun CI against an explicit commit
# input head_sha=<commit>
2. **Setup Base**: Builds and pushes base Docker image (conditional)
3. **Setup Complete**: Builds and pushes complete CI/CD Docker image
4. **Code Quality**:
Trailing whitespace check, end-of-file formatting, YAML syntax validation, and TOML syntax validation.
# Example: force base image republish
# input force_rebuild_base=true
```
5. **Backend Validation**:
Ruff formatting check, Ruff linting, Pyright type checking, Darglint docstring validation, unit tests with coverage, integration tests, and doctests (xdoctest).
Recommended rerun order during flaky infrastructure incidents:
6. **Frontend Validation**:
Prettier formatting check, ESLint linting, TypeScript compilation, and unit tests with coverage.
- E2E tests (Playwright)
1. `Build and Push CICD Images`
2. failing runtime image lane (`Build Release Images` or `Build Tester Images`)
3. downstream integration/e2e lanes
4. `Promote Release Images` (if staging validation succeeded but promotion failed)
### Local CI/CD Testing
@@ -477,7 +488,7 @@ Build and test CI/CD images locally:
1. Navigate to your pull request in Gitea
2. Check the "Checks" tab for detailed results
3. Click on individual job names to see logs
4. All jobs must pass (100% green) before merging
4. All required jobs must pass before merging (informational dependency-audit findings are non-blocking)
## Branch Protection and Merge Requirements
@@ -486,7 +497,7 @@ Build and test CI/CD images locally:
The `main` branch is protected with the following requirements:
1. **No Direct Pushes**: All changes must come through pull requests
2. **CI Must Pass**: All CI/CD jobs must be 100% green
2. **CI Must Pass**: All required CI/CD jobs must pass (dependency audits are informational)
3. **Review Required**: At least one team member approval needed
4. **Up-to-date Branch**: Feature branch must be current with main
@@ -497,7 +508,7 @@ The `main` branch is protected with the following requirements:
3. Address any CI failures by pushing fixes to the feature branch
4. Request and receive code review approval
5. Ensure branch is up-to-date with main
6. Merge pull request (only available when all requirements met)
6. Merge pull request (available when required jobs pass; informational audit failures do not block merge)
### If CI Fails
+52 -2
View File
@@ -4,6 +4,25 @@
Renovate is an automated dependency update tool that creates pull requests to keep your project dependencies up to date. This guide covers setting up Renovate for the plex-playlist project with optimal configuration.
## Repository Current Mode (2026-07)
This repository runs Renovate through `.gitea/workflows/renovate.yml`.
Current operational behavior:
1. Uses `ubuntu-act-8gb` runner due to npm/registry memory pressure.
2. Prepares Renovate container image with mirror-first strategy:
- primary: `kankali.darkhelm.lan:3001/darkhelm.org/renovate:41`
- fallback: `ghcr.io/renovatebot/renovate:41`
3. Uses digest-aware freshness checks before deciding whether local cached image is current.
4. Selects endpoint dynamically between internal and external candidates based on preflight reachability.
5. Selects token via preflight checks (repo access required) with fallback order from configured secrets.
6. Uses constrained memory settings (`RENOVATE_NODE_ARGS`) and disables OSV alerts in this runner profile.
Treat workflow behavior as source of truth; use this doc as operator guidance.
## Setup Options
### Option 1: GitHub App (Recommended for GitHub)
@@ -89,7 +108,7 @@ on:
jobs:
renovate:
runs-on: ubuntu-act
runs-on: ubuntu-act-8gb
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -101,7 +120,7 @@ jobs:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://dogar.darkhelm.org/api/v1
RENOVATE_ENDPOINT: selected at runtime from internal/external candidates
```
## Configuration Explanation
@@ -204,6 +223,37 @@ docker run --rm \
4. **Large Updates**: Major version updates may need manual review
5. **Docker Registry**: Ensure base image updates don't break builds
### Current Workflow-Specific Failures
1. **Renovate image pull failures**
- Symptom: both mirror and GHCR candidates fail.
- Check: runner DNS/egress and registry auth token validity.
2. **Endpoint preflight failures**
- Symptom: cannot reach both internal and external API endpoints.
- Check: endpoint host mapping, TLS mode, and runner network route.
3. **Token access failures**
- Symptom: API `/repos/<org>/<repo>` check returns non-200.
- Check: token scopes and secret ordering.
4. **OOM or abrupt termination**
- Symptom: process exits under memory pressure.
- Check: `RENOVATE_NODE_ARGS`, PR concurrency limits, and optional feature toggles.
## Operator Notes
For this repository, prefer updating `.gitea/workflows/renovate.yml` over local one-off Renovate service changes. Keep docs and workflow in sync after changing:
- endpoint selection logic
- token preflight/fallback order
- image source policy (mirror/fallback)
- memory and concurrency guardrails
### Quick Validation
For basic JSON validation without installing Renovate:
@@ -0,0 +1,69 @@
# ADR004: Registry Image Resolution and Auth Resilience Policy
- Status: Accepted
- Date: 2026-07-16
## Context
The CI and Renovate workflows run on self-hosted runners with intermittent DNS and network instability. Recent failures showed that image pull/push reliability depends on more than simple retries:
- registry token realms may resolve to a different host than the registry endpoint
- mirror images can become stale relative to upstream
- downstream lanes require immutable references from upstream lanes
- Renovate must operate across internal/external endpoint paths with token variability
Without an explicit policy, each job implements ad hoc behavior and drift reintroduces flakiness.
## Decision
Adopt a cross-workflow reliability policy for registry/image operations:
1. Prefer mirrored images first, then fallback upstream sources when mirror resolution fails.
2. Use digest-aware freshness checks when deciding whether local image cache is current.
3. Pin registry auth realm hosts when `WWW-Authenticate` challenge host differs from registry host.
4. Use bounded login/pull/push retry wrappers in image publication and consumption lanes.
5. Propagate and verify immutable digest references for downstream runtime validation lanes.
6. Keep retry counts/timeouts configurable as operational tuning, not architectural invariants.
## Scope
This ADR applies to:
- `.gitea/workflows/cicd.yaml`
- `.gitea/workflows/renovate.yml`
- helper scripts used for mirrored image resolution and lane orchestration
This ADR does not prescribe exact retry constants or runner sizing thresholds.
## Consequences
Positive:
- reduced CI flakiness from token realm host mismatch and transient registry failures
- stronger traceability via digest-first downstream checks
- clearer operator expectations for endpoint/token/image fallback behavior
- consistent reliability approach across CICD and Renovate workflows
Negative:
- increased workflow script complexity and duplicated helper logic inside isolated step shells
- additional maintenance burden to keep helper patterns consistent across lanes
## Alternatives Considered
1. Keep per-job ad hoc retries only.
- Rejected due to repeated regressions and inconsistent behavior.
2. Depend solely on upstream registries.
- Rejected due to local network and availability constraints.
3. Rebuild missing artifacts in downstream lanes.
- Rejected because it breaks publish-once/consume-many behavior and weakens traceability.
## Related Decisions
- `ADR002-cicd_base_image_tagging.md`
- `ADR003-deployable_runtime_image_contract.md`
+313
View File
@@ -0,0 +1,313 @@
#!/bin/bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: resolve-mirrored-image.sh --image <upstream-or-primary-image> [options]
Required:
--image IMAGE Desired image reference to use locally.
Optional:
--mirror-image IMAGE Mirror image reference to check/pull/push.
--upstream-image IMAGE Upstream source image to import into mirror when mirror is missing.
--registry-user USER Registry username for mirror auth.
--registry-password-env VAR Environment variable name containing mirror registry password/token.
--disable-remote-digest-check Skip remote digest freshness check for local images.
--local-only Only verify local presence; do not pull.
--quiet Suppress informational output except final image ref.
Behavior:
1. If IMAGE exists locally and matches remote digest, use it.
2. Otherwise try to pull IMAGE.
3. If IMAGE pull fails and MIRROR_IMAGE is set, resolve via MIRROR_IMAGE.
4. If MIRROR_IMAGE is missing and UPSTREAM_IMAGE is set, pull upstream, push mirror, then use mirror.
5. Prints the resolved local image reference on stdout.
EOF
}
log() {
if [[ "${QUIET}" != "true" ]]; then
echo "$*" >&2
fi
}
require_arg() {
local value="$1"
local name="$2"
if [[ -z "${value}" ]]; then
echo "Missing required argument: ${name}" >&2
usage >&2
exit 1
fi
}
login_if_needed() {
local image_ref="$1"
local registry password_var password
registry="${image_ref%%/*}"
if [[ -z "${registry}" || "${registry}" == "${image_ref}" ]]; then
return 0
fi
if [[ -z "${REGISTRY_USER}" || -z "${REGISTRY_PASSWORD_ENV}" ]]; then
return 0
fi
password_var="${REGISTRY_PASSWORD_ENV}"
password="${!password_var:-}"
if [[ -z "${password}" ]]; then
log "Skipping docker login for ${registry}: env ${password_var} is empty"
return 0
fi
if [[ -n "${LOGGED_IN_REGISTRIES[${registry}]:-}" ]]; then
return 0
fi
log "Logging into ${registry}"
printf '%s' "${password}" | docker login "http://${registry}" -u "${REGISTRY_USER}" --password-stdin >/dev/null
LOGGED_IN_REGISTRIES["${registry}"]=1
}
image_present_locally() {
local image_ref="$1"
docker image inspect "${image_ref}" >/dev/null 2>&1
}
is_digest_pinned_ref() {
local image_ref="$1"
[[ "${image_ref}" == *@sha256:* ]]
}
extract_remote_digest_buildx() {
local image_ref="$1"
local digest
if ! digest="$(docker buildx imagetools inspect "${image_ref}" --format '{{json .Manifest.Digest}}' 2>/dev/null)"; then
return 1
fi
digest="${digest//\"/}"
digest="${digest//$'\n'/}"
digest="${digest//$'\r'/}"
digest="${digest// /}"
if [[ "${digest}" == sha256:* ]]; then
printf '%s\n' "${digest}"
return 0
fi
return 1
}
extract_remote_digest_manifest() {
local image_ref="$1"
local digest
if ! digest="$(docker manifest inspect "${image_ref}" 2>/dev/null | sed -n 's/.*"digest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"; then
return 1
fi
if [[ "${digest}" == sha256:* ]]; then
printf '%s\n' "${digest}"
return 0
fi
return 1
}
get_remote_digest() {
local image_ref="$1"
if is_digest_pinned_ref "${image_ref}"; then
printf '%s\n' "${image_ref##*@}"
return 0
fi
if extract_remote_digest_buildx "${image_ref}"; then
return 0
fi
if extract_remote_digest_manifest "${image_ref}"; then
return 0
fi
return 1
}
local_image_matches_digest() {
local image_ref="$1"
local digest="$2"
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${image_ref}" 2>/dev/null | grep -q "@${digest}$"
}
pull_image() {
local image_ref="$1"
login_if_needed "${image_ref}"
docker pull "${image_ref}" >/dev/null
}
resolve_reference() {
local image_ref="$1"
local remote_digest
if image_present_locally "${image_ref}"; then
if [[ "${DISABLE_REMOTE_DIGEST_CHECK}" == "true" ]] || [[ "${LOCAL_ONLY}" == "true" ]] || is_digest_pinned_ref "${image_ref}"; then
log "Using locally cached image ${image_ref}"
return 0
fi
login_if_needed "${image_ref}"
if remote_digest="$(get_remote_digest "${image_ref}")"; then
if local_image_matches_digest "${image_ref}" "${remote_digest}"; then
log "Using local image ${image_ref}; remote digest matches (${remote_digest})"
return 0
fi
log "Local image ${image_ref} is stale; expected remote digest ${remote_digest}"
else
log "Unable to determine remote digest for ${image_ref}; attempting pull refresh"
fi
fi
if [[ "${LOCAL_ONLY}" == "true" ]]; then
return 1
fi
if pull_image "${image_ref}"; then
log "Pulled image ${image_ref}"
return 0
fi
return 1
}
mirror_available() {
local image_ref="$1"
if pull_image "${image_ref}"; then
return 0
fi
return 1
}
promote_upstream_to_mirror() {
require_arg "${UPSTREAM_IMAGE}" "--upstream-image"
require_arg "${MIRROR_IMAGE}" "--mirror-image"
log "Pulling upstream image ${UPSTREAM_IMAGE}"
docker pull "${UPSTREAM_IMAGE}" >/dev/null
login_if_needed "${MIRROR_IMAGE}"
log "Tagging ${UPSTREAM_IMAGE} as ${MIRROR_IMAGE}"
docker tag "${UPSTREAM_IMAGE}" "${MIRROR_IMAGE}"
log "Pushing ${MIRROR_IMAGE}"
docker push "${MIRROR_IMAGE}" >/dev/null
}
IMAGE=""
MIRROR_IMAGE=""
UPSTREAM_IMAGE=""
REGISTRY_USER=""
REGISTRY_PASSWORD_ENV=""
DISABLE_REMOTE_DIGEST_CHECK="false"
LOCAL_ONLY="false"
QUIET="false"
declare -A LOGGED_IN_REGISTRIES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
IMAGE="${2:-}"
shift 2
;;
--mirror-image)
MIRROR_IMAGE="${2:-}"
shift 2
;;
--upstream-image)
UPSTREAM_IMAGE="${2:-}"
shift 2
;;
--registry-user)
REGISTRY_USER="${2:-}"
shift 2
;;
--registry-password-env)
REGISTRY_PASSWORD_ENV="${2:-}"
shift 2
;;
--disable-remote-digest-check)
DISABLE_REMOTE_DIGEST_CHECK="true"
shift
;;
--local-only)
LOCAL_ONLY="true"
shift
;;
--quiet)
QUIET="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
require_arg "${IMAGE}" "--image"
if resolve_reference "${IMAGE}"; then
printf '%s\n' "${IMAGE}"
exit 0
fi
if [[ "${LOCAL_ONLY}" == "true" ]]; then
echo "Image unavailable via local-only mode: ${IMAGE}" >&2
exit 1
fi
if [[ -n "${MIRROR_IMAGE}" ]]; then
log "Primary image unavailable; checking mirror ${MIRROR_IMAGE}"
if resolve_reference "${MIRROR_IMAGE}"; then
printf '%s\n' "${MIRROR_IMAGE}"
exit 0
fi
if [[ -n "${UPSTREAM_IMAGE}" ]]; then
log "Mirror image ${MIRROR_IMAGE} missing; promoting from upstream ${UPSTREAM_IMAGE}"
promote_upstream_to_mirror
if ! pull_image "${MIRROR_IMAGE}"; then
echo "Failed to pull promoted mirror image: ${MIRROR_IMAGE}" >&2
exit 1
fi
printf '%s\n' "${MIRROR_IMAGE}"
exit 0
fi
fi
if [[ -n "${UPSTREAM_IMAGE}" ]]; then
log "Falling back to upstream image ${UPSTREAM_IMAGE}"
if resolve_reference "${UPSTREAM_IMAGE}"; then
printf '%s\n' "${UPSTREAM_IMAGE}"
exit 0
fi
fi
echo "Failed to resolve image via primary/mirror/upstream path" >&2
echo "primary=${IMAGE}" >&2
if [[ -n "${MIRROR_IMAGE}" ]]; then
echo "mirror=${MIRROR_IMAGE}" >&2
fi
if [[ -n "${UPSTREAM_IMAGE}" ]]; then
echo "upstream=${UPSTREAM_IMAGE}" >&2
fi
exit 1