From 7c06fa3474aff89e0f2492ccbf16b78ae054efd2 Mon Sep 17 00:00:00 2001 From: copilotcoder Date: Mon, 6 Jul 2026 10:29:31 -0400 Subject: [PATCH] Refactor CI and runtime images to two-stage builds --- .dockerignore | 1 + .gitea/workflows/cicd-source-checks.yaml | 478 ------- .gitea/workflows/cicd-start.yaml | 228 ---- .gitea/workflows/cicd-tests.yaml | 575 -------- .gitea/workflows/cicd.yaml | 1172 +++++++++++++++++ .gitea/workflows/docker-build-base.yaml | 537 -------- .gitea/workflows/docker-build-main.yaml | 481 ------- Dockerfile.backend | 27 +- Dockerfile.frontend | 36 +- .../integration/test_runtime_blackbox.py | 55 + docs/CICD_MULTI_STAGE_BUILD.md | 14 +- docs/DEPLOYABLE_RUNTIME_CONTRACT.md | 8 +- docs/DEVELOPMENT.md | 6 +- frontend/playwright.config.ts | 32 +- 14 files changed, 1279 insertions(+), 2371 deletions(-) delete mode 100644 .gitea/workflows/cicd-source-checks.yaml delete mode 100644 .gitea/workflows/cicd-start.yaml delete mode 100644 .gitea/workflows/cicd-tests.yaml create mode 100644 .gitea/workflows/cicd.yaml delete mode 100644 .gitea/workflows/docker-build-base.yaml delete mode 100644 .gitea/workflows/docker-build-main.yaml create mode 100644 backend/tests/integration/test_runtime_blackbox.py diff --git a/.dockerignore b/.dockerignore index c6e9b4e..1dd3662 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,7 @@ secrets/ .ruff_cache/ **/__pycache__/ **/.pytest_cache/ +**/.venv/ **/node_modules/ **/dist/ **/build/ diff --git a/.gitea/workflows/cicd-source-checks.yaml b/.gitea/workflows/cicd-source-checks.yaml deleted file mode 100644 index 0b2fe3e..0000000 --- a/.gitea/workflows/cicd-source-checks.yaml +++ /dev/null @@ -1,478 +0,0 @@ -name: CICD Source Checks - -on: - workflow_dispatch: - inputs: - head_sha: - description: Commit SHA to process - required: false - source_workflow: - description: Upstream workflow name - required: false - trace_id: - description: Correlation id propagated across CICD dispatch chain - required: false - base_needed: - description: Whether base rebuild is required downstream - required: false - base_hash: - description: Immutable base hash to pass downstream - required: false - -env: - GITEA_SSH_HOST: kankali.darkhelm.lan - GITEA_SSH_PORT: "2222" - GITEA_REPO_SSH_URL: ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git - GITEA_REGISTRY: kankali.darkhelm.lan:3001 - GITEA_REGISTRY_IP: 10.18.75.2 - GITEA_REGISTRY_HOST: kankali.darkhelm.lan - -concurrency: - group: source-checks-${{ github.sha }} - cancel-in-progress: true - -jobs: - setup: - name: Setup Source Checks Context - runs-on: ubuntu-act - timeout-minutes: 10 - outputs: - head_sha: ${{ steps.meta.outputs.head_sha }} - trace_id: ${{ steps.meta.outputs.trace_id }} - base_needed: ${{ steps.meta.outputs.base_needed }} - base_hash: ${{ steps.meta.outputs.base_hash }} - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Audit trigger context - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - BASE_NEEDED_INPUT: ${{ github.event.inputs.base_needed }} - BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - RESOLVED_BASE_NEEDED="${BASE_NEEDED_INPUT:-true}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-source-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESOLVED_HEAD_SHA:0:8}}" - echo "=== Dispatch Audit: CICD Source Checks ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "base_needed=${RESOLVED_BASE_NEEDED}" - echo "base_hash=${BASE_HASH_INPUT:-deferred}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - - - name: Resolve source check metadata - id: meta - env: - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - BASE_NEEDED_INPUT: ${{ github.event.inputs.base_needed }} - BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - RESOLVED_TRACE_ID="${TRACE_ID_INPUT:-cicd-source-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESOLVED_HEAD_SHA:0:8}}" - RESOLVED_BASE_NEEDED="${BASE_NEEDED_INPUT:-true}" - RESOLVED_BASE_HASH="${BASE_HASH_INPUT:-deferred}" - - echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" - echo "trace_id=${RESOLVED_TRACE_ID}" >> "$GITHUB_OUTPUT" - echo "base_needed=${RESOLVED_BASE_NEEDED}" >> "$GITHUB_OUTPUT" - echo "base_hash=${RESOLVED_BASE_HASH}" >> "$GITHUB_OUTPUT" - - - &failure_diagnostics_step - name: Failure diagnostics - if: failure() - run: | - echo "=== Failure Diagnostics ===" - date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' - echo "runner_name=${RUNNER_NAME:-unknown}" - echo "runner_hostname=${HOSTNAME:-unknown}" - uname -a || true - cat /etc/os-release 2>/dev/null || true - df -h || true - free -h || true - ps aux --sort=-%mem | head -n 30 || true - - if command -v docker >/dev/null 2>&1; then - echo "=== Docker Diagnostics ===" - docker version || true - docker info || true - docker ps -a || true - docker images --digests | head -n 50 || true - else - echo "docker not available on this runner" - fi - - echo "=== Kernel Tail ===" - dmesg | tail -n 120 || true - - source-precommit-checks-backend: - name: Source Pre-commit Checks (backend) - runs-on: ubuntu-act - timeout-minutes: 40 - needs: setup - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Configure registry host resolution - run: | - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - - name: Checkout source snapshot - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -e - umask 022 - trap 'rm -f ~/.ssh/id_rsa' EXIT - - mkdir -p ~/.ssh - echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . - - if GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1; then - git checkout FETCH_HEAD -- . - echo "Using fetched HEAD_SHA checkout: ${HEAD_SHA}" - else - git checkout HEAD -- . - echo "Falling back to default branch HEAD for source checks checkout" - fi - - test -f .pre-commit-config.yaml || { - echo "❌ Missing .pre-commit-config.yaml after checkout" - exit 1 - } - - test -f backend/pyproject.toml || { - echo "❌ Missing backend/pyproject.toml after checkout" - exit 1 - } - - test -f frontend/package.json || { - echo "❌ Missing frontend/package.json after checkout" - find . -maxdepth 3 -type f | sort | head -n 80 - exit 1 - } - - - name: Bootstrap backend toolchain - run: | - set -euo pipefail - - if ! command -v curl >/dev/null 2>&1; then - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - - if ! command -v uv >/dev/null 2>&1; then - curl -LsSf https://astral.sh/uv/install.sh | sh - fi - export PATH="$HOME/.local/bin:$PATH" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install backend dependencies - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - export UV_LINK_MODE=copy - - cd backend - uv sync --dev - - - name: Run backend pre-commit source checks - env: - CI: "true" - SKIP: "eslint,prettier,typescript-check,tsdoc-lint" - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$PATH" - cd backend - uv run pre-commit run --all-files --show-diff-on-failure --config ../.pre-commit-config.yaml - - - *failure_diagnostics_step - - source-precommit-checks-frontend: - name: Source Pre-commit Checks (frontend) - runs-on: ubuntu-act - timeout-minutes: 40 - needs: setup - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Configure registry host resolution - run: | - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - - name: Checkout source snapshot - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -e - umask 022 - trap 'rm -f ~/.ssh/id_rsa' EXIT - - mkdir -p ~/.ssh - echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . - - if GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1; then - git checkout FETCH_HEAD -- . - echo "Using fetched HEAD_SHA checkout: ${HEAD_SHA}" - else - git checkout HEAD -- . - echo "Falling back to default branch HEAD for source checks checkout" - fi - - test -f .pre-commit-config.yaml || { - echo "❌ Missing .pre-commit-config.yaml after checkout" - exit 1 - } - - test -f frontend/package.json || { - echo "❌ Missing frontend/package.json after checkout" - find . -maxdepth 3 -type f | sort | head -n 80 - exit 1 - } - - - name: Bootstrap frontend toolchain - run: | - set -euo pipefail - - if ! command -v curl >/dev/null 2>&1; then - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - - if ! command -v uv >/dev/null 2>&1; then - curl -LsSf https://astral.sh/uv/install.sh | sh - fi - export PATH="$HOME/.local/bin:$PATH" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - if ! command -v node >/dev/null 2>&1; then - apt-get update -qq - apt-get install -y -qq nodejs npm - fi - if ! command -v corepack >/dev/null 2>&1; then - npm install -g corepack - fi - - corepack enable - - - name: Install frontend dependencies - run: | - set -euo pipefail - export NODE_OPTIONS="--max-old-space-size=512" - export YARN_NETWORK_CONCURRENCY=1 - cd frontend - if ! yarn install --immutable --mode=skip-build; then - echo "Yarn install failed; retrying with npm fallback for constrained runner memory" - rm -f package-lock.json - npm install --ignore-scripts --no-audit --no-fund --prefer-offline - fi - - - name: Run frontend pre-commit source checks - env: - CI: "true" - run: | - set -euo pipefail - cd frontend - corepack yarn eslint . --max-warnings=0 - corepack yarn vue-tsc --noEmit - corepack yarn prettier --check . - - - *failure_diagnostics_step - - dispatch-build: - name: Dispatch Downstream Build - runs-on: ubuntu-act - timeout-minutes: 10 - needs: [setup, source-precommit-checks-backend, source-precommit-checks-frontend] - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Configure registry host resolution - run: | - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - - name: Dispatch downstream workflow - env: - ACTIONS_TRIGGER_TOKEN: ${{ secrets.ACTIONS_TRIGGER_TOKEN }} - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - BASE_NEEDED: ${{ needs.setup.outputs.base_needed }} - BASE_HASH: ${{ needs.setup.outputs.base_hash }} - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - TRACE_ID: ${{ needs.setup.outputs.trace_id }} - REPO_FULL: ${{ github.repository }} - HEAD_REF: ${{ github.head_ref }} - REF_NAME: ${{ github.ref_name }} - run: | - set -e - - DISPATCH_TOKEN="${ACTIONS_TRIGGER_TOKEN:-${PACKAGE_ACCESS_TOKEN:-}}" - - if [ -z "${DISPATCH_TOKEN}" ]; then - echo "❌ Missing dispatch token. Set ACTIONS_TRIGGER_TOKEN (repo write scope) or ensure PACKAGE_ACCESS_TOKEN has Actions workflow-dispatch permissions." - exit 1 - fi - - REPO_OWNER="${REPO_FULL%/*}" - REPO_NAME="${REPO_FULL#*/}" - TARGET_REF="${HEAD_REF:-${REF_NAME}}" - - if [ "${BASE_NEEDED}" = "true" ]; then - TARGET_WORKFLOW="docker-build-base.yaml" - else - TARGET_WORKFLOW="docker-build-main.yaml" - fi - - echo "route_decision workflow=${TARGET_WORKFLOW} head_sha=${HEAD_SHA} base_needed=${BASE_NEEDED} trace_id=${TRACE_ID}" - - CANDIDATE_API_BASES=() - if [ -n "${GITHUB_SERVER_URL:-}" ]; then - CANDIDATE_API_BASES+=("${GITHUB_SERVER_URL%/}/api/v1") - fi - if [ -n "${GITEA_SSH_HOST:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_SSH_HOST}:3001/api/v1") - fi - if [ -n "${GITEA_REGISTRY_HOST:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_HOST}:3001/api/v1") - fi - if [ -n "${GITEA_REGISTRY_IP:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_IP}:3001/api/v1") - fi - - ensure_curl() { - if command -v curl >/dev/null 2>&1; then - return 0 - fi - - if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - - if command -v curl >/dev/null 2>&1; then - return 0 - fi - - echo "❌ curl is required for dispatch and could not be installed" - return 1 - } - - ensure_curl - - HELPER_PATH="/tmp/dispatch-workflow.sh" - - fetch_dispatch_helper() { - local helper_ref="$1" - local api_base - for api_base in "${CANDIDATE_API_BASES[@]}"; do - helper_url="${api_base}/repos/${REPO_OWNER}/${REPO_NAME}/raw/scripts/dispatch-workflow.sh?ref=${helper_ref}" - if curl -fsS --connect-timeout 5 --max-time 20 \ - -H "Authorization: token ${DISPATCH_TOKEN}" \ - -H "User-Agent: plex-playlist-cicd-source-checks" \ - -o "${HELPER_PATH}" \ - "${helper_url}"; then - chmod +x "${HELPER_PATH}" - return 0 - fi - done - return 1 - } - - if ! fetch_dispatch_helper "${TARGET_REF}" && ! fetch_dispatch_helper "${HEAD_SHA}"; then - echo "❌ Failed to fetch scripts/dispatch-workflow.sh from repository" - exit 1 - fi - - DISPATCH_ARGS=( - --token "${DISPATCH_TOKEN}" - --repo "${REPO_FULL}" - --workflow "${TARGET_WORKFLOW}" - --ref "${TARGET_REF}" - --head-sha "${HEAD_SHA}" - --source-workflow "CICD Source Checks" - --trace-id "${TRACE_ID}" - --base-needed "${BASE_NEEDED}" - --base-hash "${BASE_HASH}" - ) - - for API_BASE in "${CANDIDATE_API_BASES[@]}"; do - DISPATCH_ARGS+=(--api-base "${API_BASE}") - done - - echo "✅ Source checks passed; dispatching ${TARGET_WORKFLOW}" - "${HELPER_PATH}" "${DISPATCH_ARGS[@]}" - - - *failure_diagnostics_step diff --git a/.gitea/workflows/cicd-start.yaml b/.gitea/workflows/cicd-start.yaml deleted file mode 100644 index 1fa74e5..0000000 --- a/.gitea/workflows/cicd-start.yaml +++ /dev/null @@ -1,228 +0,0 @@ -name: CICD Start - -on: - push: - branches: [ '**' ] - pull_request: - branches: [ main, develop ] - workflow_dispatch: - inputs: - trace_id: - description: Correlation id propagated across CICD dispatch chain - required: false - -env: - GITEA_SSH_HOST: kankali.darkhelm.lan - GITEA_SSH_PORT: "2222" - GITEA_REPO_SSH_URL: ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git - GITEA_REGISTRY: kankali.darkhelm.lan:3001 - GITEA_REGISTRY_IP: 10.18.75.2 - GITEA_REGISTRY_HOST: kankali.darkhelm.lan - -concurrency: - group: start-${{ github.ref }} - cancel-in-progress: true - -jobs: - sanity-and-routing: - name: Sanity and Base Decision - # Keep startup lightweight to avoid runner image setup stalls. - # Use the same stable self-hosted label as the downstream CICD jobs. - runs-on: ubuntu-act - timeout-minutes: 12 - outputs: - base_needed: ${{ steps.base-decision.outputs.base_needed }} - head_sha: ${{ steps.base-decision.outputs.head_sha }} - base_hash: ${{ steps.base-decision.outputs.base_hash }} - - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Audit trigger context - env: - EVENT_NAME: ${{ github.event_name }} - REPOSITORY: ${{ github.repository }} - ACTOR: ${{ github.actor }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - SHA: ${{ github.sha }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - TRACE_ID="${TRACE_ID_INPUT:-cicd-start-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${SHA:0:8}}" - echo "=== Dispatch Audit: CICD Start ===" - echo "event_name=${EVENT_NAME}" - echo "repository=${REPOSITORY}" - echo "actor=${ACTOR}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "sha=${SHA}" - echo "trace_id=${TRACE_ID}" - - - name: Configure registry host resolution - run: | - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - - name: Compute routing signal - id: base-decision - run: | - set -e - - # Keep this workflow container-agnostic: do not rely on checkout/actions/node. - # docker-build-base handles canonical base hash/existence checks. - echo "head_sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" - echo "base_hash=deferred" >> "${GITHUB_OUTPUT}" - echo "base_needed=true" >> "${GITHUB_OUTPUT}" - - echo "Routing via docker-build-base.yaml" - echo "Base hash delegated to Docker Build Base workflow" - - - name: Dispatch downstream workflow - if: steps.base-decision.outcome == 'success' && steps.base-decision.outputs.head_sha != '' && steps.base-decision.outputs.base_needed != '' - env: - ACTIONS_TRIGGER_TOKEN: ${{ secrets.ACTIONS_TRIGGER_TOKEN }} - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - BASE_NEEDED: ${{ steps.base-decision.outputs.base_needed }} - HEAD_SHA: ${{ steps.base-decision.outputs.head_sha }} - BASE_HASH: ${{ steps.base-decision.outputs.base_hash }} - REPO_FULL: ${{ github.repository }} - HEAD_REF: ${{ github.head_ref }} - REF_NAME: ${{ github.ref_name }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - set -e - - DISPATCH_TOKEN="${ACTIONS_TRIGGER_TOKEN:-${PACKAGE_ACCESS_TOKEN:-}}" - - if [ -z "${DISPATCH_TOKEN}" ]; then - echo "❌ Missing dispatch token. Set ACTIONS_TRIGGER_TOKEN (repo write scope) or ensure PACKAGE_ACCESS_TOKEN has Actions workflow-dispatch permissions." - exit 1 - fi - - REPO_OWNER="${REPO_FULL%/*}" - REPO_NAME="${REPO_FULL#*/}" - TARGET_REF="${HEAD_REF:-${REF_NAME}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-start-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${HEAD_SHA:0:8}}" - - echo "trace_id=${TRACE_ID}" - echo "target_ref=${TARGET_REF}" - - TARGET_WORKFLOW="cicd-source-checks.yaml" - - echo "route_decision workflow=${TARGET_WORKFLOW} head_sha=${HEAD_SHA} base_needed=${BASE_NEEDED} trace_id=${TRACE_ID}" - - CANDIDATE_API_BASES=() - if [ -n "${GITHUB_SERVER_URL:-}" ]; then - CANDIDATE_API_BASES+=("${GITHUB_SERVER_URL%/}/api/v1") - fi - if [ -n "${GITEA_SSH_HOST:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_SSH_HOST}:3001/api/v1") - fi - if [ -n "${GITEA_REGISTRY_HOST:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_HOST}:3001/api/v1") - fi - if [ -n "${GITEA_REGISTRY_IP:-}" ]; then - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_IP}:3001/api/v1") - fi - - ensure_curl() { - if command -v curl >/dev/null 2>&1; then - return 0 - fi - - if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - - if command -v curl >/dev/null 2>&1; then - return 0 - fi - - echo "❌ curl is required for dispatch and could not be installed" - return 1 - } - - ensure_curl - - HELPER_PATH="/tmp/dispatch-workflow.sh" - - fetch_dispatch_helper() { - local helper_ref="$1" - local api_base - for api_base in "${CANDIDATE_API_BASES[@]}"; do - helper_url="${api_base}/repos/${REPO_OWNER}/${REPO_NAME}/raw/scripts/dispatch-workflow.sh?ref=${helper_ref}" - if curl -fsS --connect-timeout 5 --max-time 20 \ - -H "Authorization: token ${DISPATCH_TOKEN}" \ - -H "User-Agent: plex-playlist-cicd-start" \ - -o "${HELPER_PATH}" \ - "${helper_url}"; then - chmod +x "${HELPER_PATH}" - return 0 - fi - done - return 1 - } - - if ! fetch_dispatch_helper "${TARGET_REF}" && ! fetch_dispatch_helper "${HEAD_SHA}"; then - echo "❌ Failed to fetch scripts/dispatch-workflow.sh from repository" - exit 1 - fi - - DISPATCH_ARGS=( - --token "${DISPATCH_TOKEN}" - --repo "${REPO_FULL}" - --workflow "${TARGET_WORKFLOW}" - --ref "${TARGET_REF}" - --head-sha "${HEAD_SHA}" - --source-workflow "CICD Start" - --trace-id "${TRACE_ID}" - --base-needed "${BASE_NEEDED}" - --base-hash "${BASE_HASH}" - ) - - for API_BASE in "${CANDIDATE_API_BASES[@]}"; do - DISPATCH_ARGS+=(--api-base "${API_BASE}") - done - - "${HELPER_PATH}" "${DISPATCH_ARGS[@]}" - - - name: Failure diagnostics - if: failure() - run: | - echo "=== Failure Diagnostics ===" - date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' - echo "runner_name=${RUNNER_NAME:-unknown}" - echo "runner_hostname=${HOSTNAME:-unknown}" - uname -a || true - cat /etc/os-release 2>/dev/null || true - df -h || true - free -h || true - ps aux --sort=-%mem | head -n 30 || true - - if command -v docker >/dev/null 2>&1; then - echo "=== Docker Diagnostics ===" - docker version || true - docker info || true - docker ps -a || true - docker images --digests | head -n 50 || true - else - echo "docker not available on this runner" - fi - - echo "=== Kernel Tail ===" - dmesg | tail -n 120 || true diff --git a/.gitea/workflows/cicd-tests.yaml b/.gitea/workflows/cicd-tests.yaml deleted file mode 100644 index d29c6ad..0000000 --- a/.gitea/workflows/cicd-tests.yaml +++ /dev/null @@ -1,575 +0,0 @@ -name: CICD Tests - -on: - workflow_dispatch: - inputs: - head_sha: - description: Commit SHA to process - required: false - deployable_backend_tag_ref: - description: Deployable backend image tag reference - required: false - deployable_backend_digest_ref: - description: Deployable backend image digest reference - required: false - source_workflow: - description: Upstream workflow name - required: false - trace_id: - description: Correlation id propagated across CICD dispatch chain - required: false - -env: - GITEA_REGISTRY: kankali.darkhelm.lan:3001 - GITEA_REGISTRY_IP: 10.18.75.2 - GITEA_REGISTRY_HOST: kankali.darkhelm.lan - -concurrency: - group: tests-${{ github.sha }} - cancel-in-progress: true - -jobs: - setup: - name: Setup Tests Context - # Use the same stable runner label as the test jobs. - runs-on: ubuntu-act - timeout-minutes: 8 - outputs: - head_sha: ${{ steps.meta.outputs.head_sha }} - deployable_backend_tag_ref: ${{ steps.meta.outputs.deployable_backend_tag_ref }} - deployable_backend_digest_ref: ${{ steps.meta.outputs.deployable_backend_digest_ref }} - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Audit trigger context - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - DEPLOYABLE_BACKEND_TAG_REF_INPUT: ${{ github.event.inputs.deployable_backend_tag_ref }} - DEPLOYABLE_BACKEND_DIGEST_REF_INPUT: ${{ github.event.inputs.deployable_backend_digest_ref }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-tests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESOLVED_HEAD_SHA:0:8}}" - echo "=== Dispatch Audit: CICD Tests ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "deployable_backend_tag_ref_input=${DEPLOYABLE_BACKEND_TAG_REF_INPUT}" - echo "deployable_backend_digest_ref_input=${DEPLOYABLE_BACKEND_DIGEST_REF_INPUT}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - - - name: Resolve head SHA - id: meta - env: - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - DEPLOYABLE_BACKEND_TAG_REF_INPUT: ${{ github.event.inputs.deployable_backend_tag_ref }} - DEPLOYABLE_BACKEND_DIGEST_REF_INPUT: ${{ github.event.inputs.deployable_backend_digest_ref }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" - echo "deployable_backend_tag_ref=${DEPLOYABLE_BACKEND_TAG_REF_INPUT}" >> "$GITHUB_OUTPUT" - echo "deployable_backend_digest_ref=${DEPLOYABLE_BACKEND_DIGEST_REF_INPUT}" >> "$GITHUB_OUTPUT" - - - &failure_diagnostics_step - name: Failure diagnostics - if: failure() - run: | - echo "=== Failure Diagnostics ===" - date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' - echo "runner_name=${RUNNER_NAME:-unknown}" - echo "runner_hostname=${HOSTNAME:-unknown}" - uname -a || true - cat /etc/os-release 2>/dev/null || true - df -h || true - free -h || true - ps aux --sort=-%mem | head -n 30 || true - - if command -v docker >/dev/null 2>&1; then - echo "=== Docker Diagnostics ===" - docker version || true - docker info || true - docker ps -a || true - docker images --digests | head -n 50 || true - else - echo "docker not available on this runner" - fi - - echo "=== Kernel Tail ===" - dmesg | tail -n 120 || true - - backend-tests: - name: Backend Tests - # Use ubuntu-act runner pool for consistent availability. - runs-on: ubuntu-act - timeout-minutes: 25 - needs: setup - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - &configure_registry_host_step - name: Configure registry host resolution - run: | - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - - &ensure_cicd_image_step - name: Ensure CICD image is available - env: - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" - if docker image inspect "${IMAGE}" >/dev/null 2>&1; then - echo "Using cached CICD image: ${IMAGE}" - else - echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin - pulled=false - for i in 1 2 3; do - echo "Pull attempt ${i}/3 for ${IMAGE}" - if docker pull "${IMAGE}"; then - pulled=true - break - fi - if [ "${i}" -lt 3 ]; then - sleep_seconds=$((5 * i)) - echo "Pull failed; retrying in ${sleep_seconds}s" - sleep "${sleep_seconds}" - fi - done - - if [ "${pulled}" != "true" ]; then - echo "❌ Failed to pull CICD image after 3 attempts: ${IMAGE}" - exit 1 - fi - fi - - - name: Run backend tests with coverage - env: - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -o pipefail - LOG_FILE="$(mktemp)" - - docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " - cd /workspace/backend && - source .venv/bin/activate && - uv run pytest -v --tb=short --cov=src --cov-report=term-missing --cov-fail-under=95 - " 2>&1 | tee "${LOG_FILE}" - - TEST_STATUS=${PIPESTATUS[0]} - if [ "${TEST_STATUS}" -ne 0 ]; then - echo "❌ Backend tests failed (exit=${TEST_STATUS})" - echo "--- Last 200 lines of backend test output ---" - tail -n 200 "${LOG_FILE}" || true - exit "${TEST_STATUS}" - fi - - - *failure_diagnostics_step - - frontend-tests: - name: Frontend Tests - # Use ubuntu-act runner pool for consistent availability. - runs-on: ubuntu-act - timeout-minutes: 25 - needs: setup - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - *configure_registry_host_step - - *ensure_cicd_image_step - - name: Run frontend tests with coverage - env: - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -o pipefail - LOG_FILE="$(mktemp)" - - docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " - cd /workspace/frontend && - yarn test:coverage --run --reporter=verbose --coverage.reporter=text --coverage.reporter=text-summary --coverage.thresholds.lines=85 --coverage.thresholds.functions=85 --coverage.thresholds.branches=85 --coverage.thresholds.statements=85 - " 2>&1 | tee "${LOG_FILE}" - - TEST_STATUS=${PIPESTATUS[0]} - if [ "${TEST_STATUS}" -ne 0 ]; then - echo "❌ Frontend tests failed (exit=${TEST_STATUS})" - echo "--- Last 200 lines of frontend test output ---" - tail -n 200 "${LOG_FILE}" || true - exit "${TEST_STATUS}" - fi - - - *failure_diagnostics_step - - xdoctest: - name: Backend Doctests - # Use ubuntu-act runner pool for consistent availability. - runs-on: ubuntu-act - timeout-minutes: 15 - needs: setup - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - *configure_registry_host_step - - *ensure_cicd_image_step - - name: Run backend doctests - env: - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -o pipefail - LOG_FILE="$(mktemp)" - - docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " - cd /workspace/backend && - source .venv/bin/activate && - uv run xdoctest src/ --quiet - " 2>&1 | tee "${LOG_FILE}" - - TEST_STATUS=${PIPESTATUS[0]} - if [ "${TEST_STATUS}" -ne 0 ]; then - echo "❌ Backend doctests failed (exit=${TEST_STATUS})" - echo "--- Last 200 lines of doctest output ---" - tail -n 200 "${LOG_FILE}" || true - exit "${TEST_STATUS}" - fi - - - *failure_diagnostics_step - - integration-tests: - name: Runtime Black-Box Integration Tests - # Pin integration tests to high-memory worker to reduce setup-stage runner churn. - runs-on: ubuntu-act-8gb - timeout-minutes: 20 - needs: [setup, backend-tests] - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - *configure_registry_host_step - - name: Run runtime black-box integration checks - env: - DEPLOYABLE_BACKEND_TAG_REF: ${{ needs.setup.outputs.deployable_backend_tag_ref }} - DEPLOYABLE_BACKEND_DIGEST_REF: ${{ needs.setup.outputs.deployable_backend_digest_ref }} - run: | - set -euo pipefail - set -o pipefail - - RUN_ID="${GITHUB_RUN_ID:-local}" - RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" - LOG_FILE="$(mktemp)" - NETWORK_NAME="plex-blackbox-${RUN_ID}-${RUN_ATTEMPT}" - DB_CONTAINER="plex-blackbox-db-${RUN_ID}-${RUN_ATTEMPT}" - BACKEND_CONTAINER="plex-blackbox-backend-${RUN_ID}-${RUN_ATTEMPT}" - DB_PASSWORD="plex_password" - DB_URL="postgresql://plex_user:${DB_PASSWORD}@${DB_CONTAINER}:5432/plex_playlist" - - fetch_backend_endpoint() { - endpoint_path="$1" - output_file="$2" - probe_tmp="$(mktemp)" - - if ! docker exec "${BACKEND_CONTAINER}" python -c "$(printf '%s\n' \ - 'import sys' \ - 'import urllib.error' \ - 'import urllib.request' \ - 'endpoint_path = sys.argv[1]' \ - 'url = f"http://127.0.0.1:8000{endpoint_path}"' \ - 'try:' \ - ' with urllib.request.urlopen(url, timeout=2) as response:' \ - ' body = response.read().decode("utf-8", errors="replace")' \ - ' print(response.status)' \ - ' print(body)' \ - 'except urllib.error.HTTPError as err:' \ - ' body = err.read().decode("utf-8", errors="replace")' \ - ' print(err.code)' \ - ' print(body)' \ - 'except Exception:' \ - ' print("000")' \ - ' print("")' - )" "${endpoint_path}" >"${probe_tmp}" 2>/dev/null - then - : >"${output_file}" - rm -f "${probe_tmp}" - echo "000" - return 0 - fi - - http_code="$(head -n 1 "${probe_tmp}")" - tail -n +2 "${probe_tmp}" >"${output_file}" - rm -f "${probe_tmp}" - echo "${http_code}" - } - - dump_failure_context() { - echo "=== Runtime Integration Failure Context ===" - docker ps -a || true - echo "--- Backend logs (tail 200) ---" - docker logs "${BACKEND_CONTAINER}" 2>&1 | tail -n 200 || true - echo "--- Database logs (tail 200) ---" - docker logs "${DB_CONTAINER}" 2>&1 | tail -n 200 || true - echo "--- Backend inspect (status/image) ---" - docker inspect --format '{{json .State}} {{.Image}}' "${BACKEND_CONTAINER}" || true - echo "--- Database inspect (status/image) ---" - docker inspect --format '{{json .State}} {{.Image}}' "${DB_CONTAINER}" || true - } - - cleanup() { - docker rm -f "${BACKEND_CONTAINER}" >/dev/null 2>&1 || true - docker rm -f "${DB_CONTAINER}" >/dev/null 2>&1 || true - docker network rm "${NETWORK_NAME}" >/dev/null 2>&1 || true - } - - trap cleanup EXIT - - { - if [ -z "${DEPLOYABLE_BACKEND_TAG_REF}" ] || [ -z "${DEPLOYABLE_BACKEND_DIGEST_REF}" ]; then - echo "❌ Missing deployable backend image references from dispatch inputs" - exit 1 - fi - - echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin - - docker pull "${DEPLOYABLE_BACKEND_TAG_REF}" - docker pull "${DEPLOYABLE_BACKEND_DIGEST_REF}" - - DEPLOYABLE_BACKEND_REPO="${DEPLOYABLE_BACKEND_DIGEST_REF%%@*}" - EXPECTED_DIGEST_REF="${DEPLOYABLE_BACKEND_DIGEST_REF}" - ACTUAL_TAG_DIGEST_REF="$({ - docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_BACKEND_TAG_REF}" \ - | grep "^${DEPLOYABLE_BACKEND_REPO}@sha256:" \ - | head -n 1 - } || true)" - - if [ -z "${ACTUAL_TAG_DIGEST_REF}" ]; then - echo "❌ Could not resolve digest from tag reference: ${DEPLOYABLE_BACKEND_TAG_REF}" - exit 1 - fi - - if [ "${ACTUAL_TAG_DIGEST_REF}" != "${EXPECTED_DIGEST_REF}" ]; then - echo "❌ Tag and digest mismatch" - echo "expected=${EXPECTED_DIGEST_REF}" - echo "actual=${ACTUAL_TAG_DIGEST_REF}" - exit 1 - fi - - echo "Resolved deployable backend tag: ${DEPLOYABLE_BACKEND_TAG_REF}" - echo "Resolved deployable backend digest: ${EXPECTED_DIGEST_REF}" - - docker network create "${NETWORK_NAME}" - - docker run -d \ - --name "${DB_CONTAINER}" \ - --network "${NETWORK_NAME}" \ - -e POSTGRES_DB=plex_playlist \ - -e POSTGRES_USER=plex_user \ - -e POSTGRES_PASSWORD="${DB_PASSWORD}" \ - postgres:16-alpine - - db_ready=false - for i in $(seq 1 30); do - if docker exec "${DB_CONTAINER}" pg_isready -U plex_user -d plex_playlist >/dev/null 2>&1; then - db_ready=true - break - fi - sleep 2 - done - if [ "${db_ready}" != "true" ]; then - echo "❌ Database did not become ready" - dump_failure_context - exit 1 - fi - - docker run -d \ - --name "${BACKEND_CONTAINER}" \ - --network "${NETWORK_NAME}" \ - -p 18000:8000 \ - -e DATABASE_URL="${DB_URL}" \ - -e ENVIRONMENT=production \ - "${EXPECTED_DIGEST_REF}" - - backend_ready=false - for i in $(seq 1 40); do - backend_running="$(docker inspect -f '{{.State.Running}}' "${BACKEND_CONTAINER}" 2>/dev/null || echo false)" - if [ "${backend_running}" != "true" ]; then - echo "❌ Backend container exited before becoming healthy" - dump_failure_context - exit 1 - fi - - health_code="$(fetch_backend_endpoint "/health" /tmp/blackbox-health.json)" - if [ "${health_code}" = "200" ]; then - backend_ready=true - break - fi - sleep 2 - done - if [ "${backend_ready}" != "true" ]; then - echo "❌ Backend did not become healthy" - cat /tmp/blackbox-health.json 2>/dev/null || true - dump_failure_context - exit 1 - fi - - root_code="$(fetch_backend_endpoint "/" /tmp/blackbox-root.json)" - if [ "${root_code}" != "200" ] || ! grep -q 'Plex Playlist Backend API' /tmp/blackbox-root.json; then - echo "❌ Root endpoint validation failed" - cat /tmp/blackbox-root.json 2>/dev/null || true - dump_failure_context - exit 1 - fi - - compatibility_code="$(fetch_backend_endpoint "/compatibility" /tmp/blackbox-compatibility.json)" - if [ "${compatibility_code}" != "200" ] || ! grep -q '"ok":true' /tmp/blackbox-compatibility.json; then - echo "❌ Compatibility endpoint validation failed" - cat /tmp/blackbox-compatibility.json 2>/dev/null || true - dump_failure_context - exit 1 - fi - - health_code="$(fetch_backend_endpoint "/health" /tmp/blackbox-health.json)" - if [ "${health_code}" != "200" ] || ! grep -q '"status":"healthy"' /tmp/blackbox-health.json; then - echo "❌ Health endpoint validation failed" - cat /tmp/blackbox-health.json 2>/dev/null || true - dump_failure_context - exit 1 - fi - - echo "✅ Runtime black-box integration checks passed" - } 2>&1 | tee "${LOG_FILE}" - - TEST_STATUS=${PIPESTATUS[0]} - if [ "${TEST_STATUS}" -ne 0 ]; then - echo "❌ Runtime black-box integration checks failed (exit=${TEST_STATUS})" - echo "--- Last 200 lines of runtime black-box integration output ---" - tail -n 200 "${LOG_FILE}" || true - exit "${TEST_STATUS}" - fi - - - *failure_diagnostics_step - - e2e-tests: - name: End-to-End Tests - # Use ubuntu-act runner pool for consistent availability. - runs-on: ubuntu-act - timeout-minutes: 30 - needs: [setup, frontend-tests] - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - *configure_registry_host_step - - *ensure_cicd_image_step - - name: Run E2E tests - env: - HEAD_SHA: ${{ needs.setup.outputs.head_sha }} - run: | - set -o pipefail - LOG_FILE="$(mktemp)" - - docker run --rm -e CI=true "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " - cd /workspace/frontend && - if [ -d 'tests/e2e' ] || grep -q 'playwright' package.json; then - yarn playwright --version && - PW_BROWSER_PATH="\${PLAYWRIGHT_BROWSERS_PATH:-/root/.cache/ms-playwright}" && - if find "\${PW_BROWSER_PATH}" -maxdepth 1 -type d -name 'chromium-*' | grep -q .; then - echo 'Using preinstalled Playwright Chromium from '"\${PW_BROWSER_PATH}" - else - browser_ok=false && - for i in 1 2 3; do - echo 'Playwright browser install attempt' "\$i"'/3' && - if timeout 1800 yarn playwright install chromium; then - browser_ok=true - break - fi - if [ "\$i" -lt 3 ]; then - echo 'Playwright install attempt failed; retrying in 20s' - sleep 20 - fi - done && - if [ "\$browser_ok" != 'true' ]; then - echo '❌ Playwright browser install failed after 3 attempts' - exit 1 - fi - fi && - yarn test:e2e --reporter=list --timeout=90000 - else - echo 'No E2E tests found' - fi - " 2>&1 | tee "${LOG_FILE}" - - TEST_STATUS=${PIPESTATUS[0]} - if [ "${TEST_STATUS}" -ne 0 ]; then - echo "❌ E2E tests failed (exit=${TEST_STATUS})" - echo "--- Last 200 lines of E2E output ---" - tail -n 200 "${LOG_FILE}" || true - exit "${TEST_STATUS}" - fi - - - *failure_diagnostics_step diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml new file mode 100644 index 0000000..6d4a551 --- /dev/null +++ b/.gitea/workflows/cicd.yaml @@ -0,0 +1,1172 @@ +name: CICD + +on: + push: + branches: ["**"] + pull_request: + branches: [main, develop] + workflow_dispatch: + inputs: + head_sha: + description: Commit SHA to process + required: false + base_hash: + description: Immutable base hash to use for CICD base image + required: false + force_rebuild_base: + description: Force CICD base rebuild + required: false + default: "false" + trace_id: + description: Correlation id propagated across CICD jobs + required: false + +env: + GITEA_SSH_HOST: kankali.darkhelm.lan + GITEA_SSH_PORT: "2222" + GITEA_REPO_SSH_URL: ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git + GITEA_REGISTRY: kankali.darkhelm.lan:3001 + GITEA_REGISTRY_IP: 10.18.75.2 + GITEA_REGISTRY_HOST: kankali.darkhelm.lan + +defaults: + run: + shell: bash + +concurrency: + group: cicd-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-base: + name: Build and Publish CICD Base Image + runs-on: ubuntu-act + timeout-minutes: 35 + outputs: + base_hash: ${{ steps.base-state.outputs.base_hash }} + head_sha: ${{ steps.meta.outputs.head_sha }} + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - name: Resolve head SHA + id: meta + env: + HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} + HEAD_SHA_FALLBACK: ${{ github.sha }} + run: | + RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" + echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" + + - name: Minimal checkout for base inputs + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + umask 077 + trap 'rm -f ~/.ssh/id_rsa' EXIT + + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + mkdir -p ~/.ssh + echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . + + if GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1; then + git checkout FETCH_HEAD -- Dockerfile.cicd-base .dockerignore scripts/compute-cicd-base-hash.sh + else + git checkout HEAD -- Dockerfile.cicd-base .dockerignore scripts/compute-cicd-base-hash.sh + fi + + chmod +x scripts/compute-cicd-base-hash.sh + + - name: Compute base hash and inspect registry + id: base-state + env: + PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + FORCE_REBUILD: ${{ github.event.inputs.force_rebuild_base }} + run: | + set -euo pipefail + BASE_HASH=$(./scripts/compute-cicd-base-hash.sh) + BASE_REF_HASH="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:${BASE_HASH}" + BASE_REF_LATEST="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:latest" + + echo "base_hash=${BASE_HASH}" >> "$GITHUB_OUTPUT" + echo "base_ref_hash=${BASE_REF_HASH}" >> "$GITHUB_OUTPUT" + echo "base_ref_latest=${BASE_REF_LATEST}" >> "$GITHUB_OUTPUT" + + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin >/dev/null + + if [ "${FORCE_REBUILD:-false}" = "true" ]; then + echo "needs_build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if timeout 900 docker pull "${BASE_REF_HASH}" >/dev/null 2>&1; then + echo "needs_build=false" >> "$GITHUB_OUTPUT" + else + echo "needs_build=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push base image + if: steps.base-state.outputs.needs_build == 'true' + env: + PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + BASE_HASH: ${{ steps.base-state.outputs.base_hash }} + BASE_REF_HASH: ${{ steps.base-state.outputs.base_ref_hash }} + BASE_REF_LATEST: ${{ steps.base-state.outputs.base_ref_latest }} + run: | + set -euo pipefail + + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + + PLAYWRIGHT_BROWSERS_MIRROR_TAG="${GITEA_REGISTRY}/darkhelm.org/playwright-browsers:v1.56.1-jammy" + docker pull "${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" + + PLAYWRIGHT_BROWSERS_IMAGE=$(docker image inspect --format='{{index .RepoDigests 0}}' "${PLAYWRIGHT_BROWSERS_MIRROR_TAG}") + + docker build --progress=plain -f Dockerfile.cicd-base \ + --build-arg BASE_IMAGE_VERSION="v1.0.0-${BASE_HASH}" \ + --build-arg BASE_IMAGE_HASH="${BASE_HASH}" \ + --build-arg PLAYWRIGHT_BROWSERS_IMAGE="${PLAYWRIGHT_BROWSERS_IMAGE}" \ + -t cicd-base:latest . + + docker tag cicd-base:latest "${BASE_REF_HASH}" + docker tag cicd-base:latest "${BASE_REF_LATEST}" + docker push "${BASE_REF_HASH}" + docker push "${BASE_REF_LATEST}" + + - &failure_diagnostics_step + name: Failure diagnostics + if: failure() + run: | + echo "=== Failure Diagnostics ===" + date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' + echo "runner_name=${RUNNER_NAME:-unknown}" + echo "runner_hostname=${HOSTNAME:-unknown}" + uname -a || true + cat /etc/os-release 2>/dev/null || true + df -h || true + free -h || true + ps aux --sort=-%mem | head -n 30 || true + if command -v docker >/dev/null 2>&1; then + echo "=== Docker Diagnostics ===" + docker version || true + docker info || true + docker ps -a || true + docker images --digests | head -n 50 || true + fi + dmesg | tail -n 120 || true + + build_cicd: + name: Build and Push CICD Image + runs-on: ubuntu-act-8gb + needs: publish-base + timeout-minutes: 60 + outputs: + head_sha: ${{ steps.meta.outputs.head_sha }} + steps: + - name: Resolve head SHA + id: meta + run: | + echo "head_sha=${{ needs.publish-base.outputs.head_sha }}" >> "$GITHUB_OUTPUT" + + - name: Minimal checkout for CICD build inputs + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + umask 077 + trap 'rm -f ~/.ssh/id_rsa' EXIT + + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + mkdir -p ~/.ssh + echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . + + if GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1; then + git checkout FETCH_HEAD -- .dockerignore Dockerfile.cicd Dockerfile.cicd-base scripts/compute-cicd-base-hash.sh + else + git checkout HEAD -- .dockerignore Dockerfile.cicd Dockerfile.cicd-base scripts/compute-cicd-base-hash.sh + fi + chmod +x scripts/compute-cicd-base-hash.sh + + - name: Build and push complete CICD image + env: + PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} + BASE_HASH_FROM_BUILD: ${{ needs.publish-base.outputs.base_hash }} + run: | + set -euo pipefail + umask 077 + trap 'rm -f /tmp/ssh_key' EXIT + + echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + + BASE_HASH="${BASE_HASH_INPUT:-${BASE_HASH_FROM_BUILD}}" + BASE_IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:${BASE_HASH}" + docker pull "${BASE_IMAGE}" + + echo "${SSH_PRIVATE_KEY}" > /tmp/ssh_key + chmod 600 /tmp/ssh_key + + docker build -f Dockerfile.cicd \ + --secret id=ssh_private_key,src=/tmp/ssh_key \ + --add-host "${GITEA_SSH_HOST}:${GITEA_REGISTRY_IP}" \ + --build-arg GITHUB_SHA="${HEAD_SHA}" \ + --build-arg CICD_BASE_IMAGE="${BASE_IMAGE}" \ + -t cicd:latest . + + docker tag cicd:latest "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:latest" + docker tag cicd:latest "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + docker push "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:latest" + docker push "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + + - *failure_diagnostics_step + + backend-tests: + name: Backend Tests + # Use ubuntu-act runner pool for consistent availability. + runs-on: ubuntu-act + timeout-minutes: 25 + needs: build_cicd + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - &configure_registry_host_step + name: Configure registry host resolution + run: | + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + - &ensure_cicd_image_step + name: Ensure CICD image is available + env: + HEAD_SHA: ${{ needs.build_cicd.outputs.head_sha }} + run: | + IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + if docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "Using cached CICD image: ${IMAGE}" + else + echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin + pulled=false + for i in 1 2 3; do + echo "Pull attempt ${i}/3 for ${IMAGE}" + if docker pull "${IMAGE}"; then + pulled=true + break + fi + if [ "${i}" -lt 3 ]; then + sleep_seconds=$((5 * i)) + echo "Pull failed; retrying in ${sleep_seconds}s" + sleep "${sleep_seconds}" + fi + done + + if [ "${pulled}" != "true" ]; then + echo "❌ Failed to pull CICD image after 3 attempts: ${IMAGE}" + exit 1 + fi + fi + + - name: Run backend tests with coverage + env: + HEAD_SHA: ${{ needs.build_cicd.outputs.head_sha }} + run: | + set -o pipefail + LOG_FILE="$(mktemp)" + + docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " + cd /workspace/backend && + source .venv/bin/activate && + uv run pytest -v --tb=short --cov=src --cov-report=term-missing --cov-fail-under=95 + " 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ Backend tests failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of backend test output ---" + tail -n 200 "${LOG_FILE}" || true + exit "${TEST_STATUS}" + fi + + - *failure_diagnostics_step + + precommit-tests: + name: Pre-commit Checks + # Use ubuntu-act runner pool for consistent availability. + runs-on: ubuntu-act + timeout-minutes: 30 + needs: build_cicd + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - *configure_registry_host_step + - *ensure_cicd_image_step + - name: Run pre-commit checks + env: + HEAD_SHA: ${{ needs.build_cicd.outputs.head_sha }} + run: | + set -o pipefail + LOG_FILE="$(mktemp)" + + docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " + cd /workspace && + if [ ! -d .git ]; then + git init /workspace + fi && + /workspace/backend/.venv/bin/pre-commit run --all-files --show-diff-on-failure --config .pre-commit-config.yaml + " 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ Pre-commit checks failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of pre-commit output ---" + tail -n 200 "${LOG_FILE}" || true + exit "${TEST_STATUS}" + fi + + - *failure_diagnostics_step + + frontend-tests: + name: Frontend Tests + # Use ubuntu-act runner pool for consistent availability. + runs-on: ubuntu-act + timeout-minutes: 25 + needs: build_cicd + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - *configure_registry_host_step + - *ensure_cicd_image_step + - name: Run frontend tests with coverage + env: + HEAD_SHA: ${{ needs.build_cicd.outputs.head_sha }} + run: | + set -o pipefail + LOG_FILE="$(mktemp)" + + docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " + cd /workspace/frontend && + yarn test:coverage --run --reporter=verbose --coverage.reporter=text --coverage.reporter=text-summary --coverage.thresholds.lines=85 --coverage.thresholds.functions=85 --coverage.thresholds.branches=85 --coverage.thresholds.statements=85 + " 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ Frontend tests failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of frontend test output ---" + tail -n 200 "${LOG_FILE}" || true + exit "${TEST_STATUS}" + fi + + - *failure_diagnostics_step + + xdoctest: + name: Backend Doctests + # Use ubuntu-act runner pool for consistent availability. + runs-on: ubuntu-act + timeout-minutes: 15 + needs: build_cicd + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - *configure_registry_host_step + - *ensure_cicd_image_step + - name: Run backend doctests + env: + HEAD_SHA: ${{ needs.build_cicd.outputs.head_sha }} + run: | + set -o pipefail + LOG_FILE="$(mktemp)" + + docker run --rm "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" bash -c " + cd /workspace/backend && + source .venv/bin/activate && + uv run xdoctest src/ --quiet + " 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ Backend doctests failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of doctest output ---" + tail -n 200 "${LOG_FILE}" || true + exit "${TEST_STATUS}" + fi + + - *failure_diagnostics_step + + runtime_images: + name: Build and Publish Runtime Images + runs-on: ubuntu-act-8gb + needs: [build_cicd, precommit-tests, backend-tests, frontend-tests, xdoctest] + timeout-minutes: 45 + outputs: + head_sha: ${{ steps.meta.outputs.head_sha }} + deployable_backend_tag_ref: ${{ steps.deployable_backend_ref.outputs.deployable_backend_tag_ref }} + deployable_backend_digest_ref: ${{ steps.deployable_backend_ref.outputs.deployable_backend_digest_ref }} + deployable_frontend_tag_ref: ${{ steps.deployable_frontend_ref.outputs.deployable_frontend_tag_ref }} + deployable_frontend_digest_ref: ${{ steps.deployable_frontend_ref.outputs.deployable_frontend_digest_ref }} + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - name: Resolve head SHA + id: meta + run: | + echo "head_sha=${{ needs.build_cicd.outputs.head_sha }}" >> "$GITHUB_OUTPUT" + + - name: Minimal checkout for runtime image inputs + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + umask 077 + trap 'rm -f ~/.ssh/id_rsa' EXIT + + if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then + echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts + fi + + mkdir -p ~/.ssh + echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . + + if GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ + git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1; then + git checkout FETCH_HEAD -- \ + .dockerignore \ + Dockerfile.backend \ + Dockerfile.frontend \ + backend \ + frontend \ + scripts/check-dockerfile-boundaries.sh \ + scripts/verify-deployable-image-purity.sh + else + git checkout HEAD -- \ + .dockerignore \ + Dockerfile.backend \ + Dockerfile.frontend \ + backend \ + frontend \ + scripts/check-dockerfile-boundaries.sh \ + scripts/verify-deployable-image-purity.sh + fi + + - name: Verify deployable runtime boundaries + run: | + set -euo pipefail + bash ./scripts/check-dockerfile-boundaries.sh + + - name: Build and verify deployable runtime image purity + env: + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + + docker build -f Dockerfile.backend -t deployable-backend:"${HEAD_SHA}" . + docker build -f Dockerfile.frontend --target production -t deployable-frontend:"${HEAD_SHA}" . + + bash ./scripts/verify-deployable-image-purity.sh --image deployable-backend:"${HEAD_SHA}" --profile backend + bash ./scripts/verify-deployable-image-purity.sh --image deployable-frontend:"${HEAD_SHA}" --profile frontend + + - name: Push deployable backend runtime image + id: deployable_backend_ref + env: + PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + DEPLOYABLE_BACKEND_REPO="${GITEA_REGISTRY}/darkhelm.org/deployable-backend" + DEPLOYABLE_BACKEND_TAG_REF="${DEPLOYABLE_BACKEND_REPO}:${HEAD_SHA}" + docker tag "deployable-backend:${HEAD_SHA}" "${DEPLOYABLE_BACKEND_TAG_REF}" + docker push "${DEPLOYABLE_BACKEND_TAG_REF}" + docker pull "${DEPLOYABLE_BACKEND_TAG_REF}" >/dev/null + DEPLOYABLE_BACKEND_DIGEST_REF="$({ docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_BACKEND_TAG_REF}" | grep "^${DEPLOYABLE_BACKEND_REPO}@sha256:" | head -n 1; } || true)" + echo "deployable_backend_tag_ref=${DEPLOYABLE_BACKEND_TAG_REF}" >> "$GITHUB_OUTPUT" + echo "deployable_backend_digest_ref=${DEPLOYABLE_BACKEND_DIGEST_REF}" >> "$GITHUB_OUTPUT" + + - name: Push deployable frontend runtime image + id: deployable_frontend_ref + env: + PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + HEAD_SHA: ${{ steps.meta.outputs.head_sha }} + run: | + set -euo pipefail + echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + DEPLOYABLE_FRONTEND_REPO="${GITEA_REGISTRY}/darkhelm.org/deployable-frontend" + DEPLOYABLE_FRONTEND_TAG_REF="${DEPLOYABLE_FRONTEND_REPO}:${HEAD_SHA}" + docker tag "deployable-frontend:${HEAD_SHA}" "${DEPLOYABLE_FRONTEND_TAG_REF}" + docker push "${DEPLOYABLE_FRONTEND_TAG_REF}" + docker pull "${DEPLOYABLE_FRONTEND_TAG_REF}" >/dev/null + DEPLOYABLE_FRONTEND_DIGEST_REF="$({ docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_FRONTEND_TAG_REF}" | grep "^${DEPLOYABLE_FRONTEND_REPO}@sha256:" | head -n 1; } || true)" + echo "deployable_frontend_tag_ref=${DEPLOYABLE_FRONTEND_TAG_REF}" >> "$GITHUB_OUTPUT" + echo "deployable_frontend_digest_ref=${DEPLOYABLE_FRONTEND_DIGEST_REF}" >> "$GITHUB_OUTPUT" + + - *failure_diagnostics_step + + integration-tests: + name: Runtime Black-Box Integration Tests + # Pin integration tests to high-memory worker to reduce setup-stage runner churn. + runs-on: ubuntu-act-8gb + timeout-minutes: 20 + needs: runtime_images + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - *configure_registry_host_step + - name: Run runtime black-box integration checks via compose + env: + HEAD_SHA: ${{ needs.runtime_images.outputs.head_sha }} + DEPLOYABLE_BACKEND_TAG_REF: ${{ needs.runtime_images.outputs.deployable_backend_tag_ref }} + DEPLOYABLE_BACKEND_DIGEST_REF: ${{ needs.runtime_images.outputs.deployable_backend_digest_ref }} + run: | + set -euo pipefail + set -o pipefail + + RUN_ID="${GITHUB_RUN_ID:-local}" + RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" + LOG_FILE="$(mktemp)" + COMPOSE_FILE="/tmp/compose.ci.integration.yaml" + COMPOSE_PROJECT_NAME="plex-int-${RUN_ID}-${RUN_ATTEMPT}" + DB_PASSWORD="plex_password" + DB_URL="postgresql://plex_user:${DB_PASSWORD}@database:5432/plex_playlist" + CICD_IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + + if ! command -v docker >/dev/null 2>&1; then + echo "❌ Docker is required" + exit 1 + fi + if ! docker compose version >/dev/null 2>&1; then + echo "❌ docker compose plugin is required" + exit 1 + fi + + cat >"${COMPOSE_FILE}" <<'EOF' + services: + database: + image: postgres:16-alpine + environment: + POSTGRES_DB: plex_playlist + POSTGRES_USER: plex_user + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U plex_user -d plex_playlist"] + interval: 5s + timeout: 3s + retries: 12 + + backend: + image: ${DEPLOYABLE_BACKEND_IMAGE} + environment: + DATABASE_URL: ${DB_URL} + ENVIRONMENT: production + depends_on: + database: + condition: service_healthy + + integration-tests: + image: ${CICD_IMAGE} + depends_on: + backend: + condition: service_started + environment: + CI: "true" + INTEGRATION_BACKEND_URL: http://backend:8000 + EOF + + fetch_backend_endpoint() { + endpoint_path="$1" + output_file="$2" + probe_tmp="$(mktemp)" + + if ! docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" exec -T backend python -c "$(printf '%s\n' \ + 'import sys' \ + 'import urllib.error' \ + 'import urllib.request' \ + 'endpoint_path = sys.argv[1]' \ + 'url = f"http://127.0.0.1:8000{endpoint_path}"' \ + 'try:' \ + ' with urllib.request.urlopen(url, timeout=2) as response:' \ + ' body = response.read().decode("utf-8", errors="replace")' \ + ' print(response.status)' \ + ' print(body)' \ + 'except urllib.error.HTTPError as err:' \ + ' body = err.read().decode("utf-8", errors="replace")' \ + ' print(err.code)' \ + ' print(body)' \ + 'except Exception:' \ + ' print("000")' \ + ' print("")' + )" "${endpoint_path}" >"${probe_tmp}" 2>/dev/null + then + : >"${output_file}" + rm -f "${probe_tmp}" + echo "000" + return 0 + fi + + http_code="$(head -n 1 "${probe_tmp}")" + tail -n +2 "${probe_tmp}" >"${output_file}" + rm -f "${probe_tmp}" + echo "${http_code}" + } + + dump_failure_context() { + echo "=== Runtime Integration Failure Context ===" + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" ps || true + echo "--- Compose logs (tail 200) ---" + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" logs --no-color --tail=200 || true + } + + cleanup() { + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" down -v --remove-orphans >/dev/null 2>&1 || true + } + + trap cleanup EXIT + + { + if [ -z "${DEPLOYABLE_BACKEND_TAG_REF}" ] || [ -z "${DEPLOYABLE_BACKEND_DIGEST_REF}" ]; then + echo "❌ Missing deployable backend image references from dispatch inputs" + exit 1 + fi + + echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin + + docker pull "${CICD_IMAGE}" + docker pull "${DEPLOYABLE_BACKEND_TAG_REF}" + docker pull "${DEPLOYABLE_BACKEND_DIGEST_REF}" + + DEPLOYABLE_BACKEND_REPO="${DEPLOYABLE_BACKEND_DIGEST_REF%%@*}" + EXPECTED_DIGEST_REF="${DEPLOYABLE_BACKEND_DIGEST_REF}" + ACTUAL_TAG_DIGEST_REF="$({ + docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_BACKEND_TAG_REF}" \ + | grep "^${DEPLOYABLE_BACKEND_REPO}@sha256:" \ + | head -n 1 + } || true)" + + if [ -z "${ACTUAL_TAG_DIGEST_REF}" ]; then + echo "❌ Could not resolve digest from tag reference: ${DEPLOYABLE_BACKEND_TAG_REF}" + exit 1 + fi + + if [ "${ACTUAL_TAG_DIGEST_REF}" != "${EXPECTED_DIGEST_REF}" ]; then + echo "❌ Tag and digest mismatch" + echo "expected=${EXPECTED_DIGEST_REF}" + echo "actual=${ACTUAL_TAG_DIGEST_REF}" + exit 1 + fi + + echo "Resolved deployable backend tag: ${DEPLOYABLE_BACKEND_TAG_REF}" + echo "Resolved deployable backend digest: ${EXPECTED_DIGEST_REF}" + + export DB_PASSWORD DB_URL CICD_IMAGE DEPLOYABLE_BACKEND_IMAGE="${EXPECTED_DIGEST_REF}" + + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d database backend + + db_ready=false + for i in $(seq 1 30); do + if docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" exec -T database pg_isready -U plex_user -d plex_playlist >/dev/null 2>&1; then + db_ready=true + break + fi + sleep 2 + done + if [ "${db_ready}" != "true" ]; then + echo "❌ Database did not become ready" + dump_failure_context + exit 1 + fi + + backend_ready=false + for i in $(seq 1 40); do + backend_running_count="$(docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" ps --status running --services backend | wc -l)" + if [ "${backend_running_count}" -eq 0 ]; then + echo "❌ Backend container exited before becoming healthy" + dump_failure_context + exit 1 + fi + + health_code="$(fetch_backend_endpoint "/health" /tmp/blackbox-health.json)" + if [ "${health_code}" = "200" ]; then + backend_ready=true + break + fi + sleep 2 + done + if [ "${backend_ready}" != "true" ]; then + echo "❌ Backend did not become healthy" + cat /tmp/blackbox-health.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + root_code="$(fetch_backend_endpoint "/" /tmp/blackbox-root.json)" + if [ "${root_code}" != "200" ] || ! grep -q 'Plex Playlist Backend API' /tmp/blackbox-root.json; then + echo "❌ Root endpoint validation failed" + cat /tmp/blackbox-root.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + compatibility_code="$(fetch_backend_endpoint "/compatibility" /tmp/blackbox-compatibility.json)" + if [ "${compatibility_code}" != "200" ] || ! grep -q '"ok":true' /tmp/blackbox-compatibility.json; then + echo "❌ Compatibility endpoint validation failed" + cat /tmp/blackbox-compatibility.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + health_code="$(fetch_backend_endpoint "/health" /tmp/blackbox-health.json)" + if [ "${health_code}" != "200" ] || ! grep -q '"status":"healthy"' /tmp/blackbox-health.json; then + echo "❌ Health endpoint validation failed" + cat /tmp/blackbox-health.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" run --rm integration-tests \ + bash -c "cd /workspace/backend && source .venv/bin/activate && uv run pytest -v --tb=short tests/integration/test_runtime_blackbox.py" + + echo "✅ Runtime integration checks passed" + } 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ Runtime integration checks failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of runtime integration output ---" + tail -n 200 "${LOG_FILE}" || true + exit "${TEST_STATUS}" + fi + + - *failure_diagnostics_step + + e2e-tests: + name: End-to-End Tests + # Use ubuntu-act runner pool for consistent availability. + runs-on: ubuntu-act-8gb + timeout-minutes: 45 + needs: runtime_images + steps: + - name: Identify runner + run: | + echo "=== Runner Identity ===" + echo "runner_name=${RUNNER_NAME:-}" + echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" + echo "runner_hostname_env=${HOSTNAME:-unknown}" + echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" + echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" + echo "runner_os=${RUNNER_OS:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + - *configure_registry_host_step + - *ensure_cicd_image_step + - name: Run E2E tests against runtime services via compose + env: + HEAD_SHA: ${{ needs.runtime_images.outputs.head_sha }} + DEPLOYABLE_BACKEND_TAG_REF: ${{ needs.runtime_images.outputs.deployable_backend_tag_ref }} + DEPLOYABLE_BACKEND_DIGEST_REF: ${{ needs.runtime_images.outputs.deployable_backend_digest_ref }} + DEPLOYABLE_FRONTEND_TAG_REF: ${{ needs.runtime_images.outputs.deployable_frontend_tag_ref }} + DEPLOYABLE_FRONTEND_DIGEST_REF: ${{ needs.runtime_images.outputs.deployable_frontend_digest_ref }} + run: | + set -euo pipefail + set -o pipefail + LOG_FILE="$(mktemp)" + ARTIFACT_DIR="$(mktemp -d)" + RUN_ID="${GITHUB_RUN_ID:-local}" + RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" + COMPOSE_FILE="/tmp/compose.ci.e2e.yaml" + COMPOSE_PROJECT_NAME="plex-e2e-${RUN_ID}-${RUN_ATTEMPT}" + DB_PASSWORD="plex_password" + DB_URL="postgresql://plex_user:${DB_PASSWORD}@database:5432/plex_playlist" + CICD_IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + FRONTEND_URL_RUNNER="http://frontend:80" + + if ! command -v docker >/dev/null 2>&1; then + echo "❌ Docker is required" + exit 1 + fi + if ! docker compose version >/dev/null 2>&1; then + echo "❌ docker compose plugin is required" + exit 1 + fi + + cat >"${COMPOSE_FILE}" <<'EOF' + services: + database: + image: postgres:16-alpine + environment: + POSTGRES_DB: plex_playlist + POSTGRES_USER: plex_user + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U plex_user -d plex_playlist"] + interval: 5s + timeout: 3s + retries: 12 + + backend: + image: ${DEPLOYABLE_BACKEND_IMAGE} + environment: + DATABASE_URL: ${DB_URL} + ENVIRONMENT: production + depends_on: + database: + condition: service_healthy + + frontend: + image: ${DEPLOYABLE_FRONTEND_IMAGE} + depends_on: + backend: + condition: service_started + + e2e-tests: + image: ${CICD_IMAGE} + depends_on: + frontend: + condition: service_started + environment: + CI: "true" + PLAYWRIGHT_BASE_URL: http://frontend:80 + PLAYWRIGHT_OUTPUT_DIR: /tmp/playwright-artifacts + PLAYWRIGHT_JUNIT_OUTPUT_FILE: /tmp/playwright-artifacts/playwright-results.xml + volumes: + - ${E2E_ARTIFACT_DIR}:/tmp/playwright-artifacts + EOF + + fetch_http() { + container_name="$1" + endpoint_path="$2" + output_file="$3" + probe_tmp="$(mktemp)" + + if ! docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" exec -T "${container_name}" python -c "$(printf '%s\n' \ + 'import sys' \ + 'import urllib.error' \ + 'import urllib.request' \ + 'endpoint_path = sys.argv[1]' \ + 'url = f\"http://127.0.0.1:8000{endpoint_path}\"' \ + 'try:' \ + ' with urllib.request.urlopen(url, timeout=2) as response:' \ + ' body = response.read().decode(\"utf-8\", errors=\"replace\")' \ + ' print(response.status)' \ + ' print(body)' \ + 'except urllib.error.HTTPError as err:' \ + ' body = err.read().decode(\"utf-8\", errors=\"replace\")' \ + ' print(err.code)' \ + ' print(body)' \ + 'except Exception:' \ + ' print(\"000\")' \ + ' print(\"\")')" "${endpoint_path}" >"${probe_tmp}" 2>/dev/null; then + : >"${output_file}" + rm -f "${probe_tmp}" + echo "000" + return 0 + fi + + http_code="$(head -n 1 "${probe_tmp}")" + tail -n +2 "${probe_tmp}" >"${output_file}" + rm -f "${probe_tmp}" + echo "${http_code}" + } + + fetch_url_from_backend() { + target_url="$1" + output_file="$2" + probe_tmp="$(mktemp)" + + if ! docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" exec -T backend python -c "$(printf '%s\n' \ + 'import sys' \ + 'import urllib.error' \ + 'import urllib.request' \ + 'target_url = sys.argv[1]' \ + 'try:' \ + ' with urllib.request.urlopen(target_url, timeout=2) as response:' \ + ' body = response.read().decode(\"utf-8\", errors=\"replace\")' \ + ' print(response.status)' \ + ' print(body)' \ + 'except urllib.error.HTTPError as err:' \ + ' body = err.read().decode(\"utf-8\", errors=\"replace\")' \ + ' print(err.code)' \ + ' print(body)' \ + 'except Exception:' \ + ' print(\"000\")' \ + ' print(\"\")')" "${target_url}" >"${probe_tmp}" 2>/dev/null; then + : >"${output_file}" + rm -f "${probe_tmp}" + echo "000" + return 0 + fi + + http_code="$(head -n 1 "${probe_tmp}")" + tail -n +2 "${probe_tmp}" >"${output_file}" + rm -f "${probe_tmp}" + echo "${http_code}" + } + + dump_failure_context() { + echo "=== Runtime E2E Failure Context ===" + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" ps || true + echo "--- Compose logs (tail 200) ---" + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" logs --no-color --tail=200 || true + echo "--- Artifact directory ---" + find "${ARTIFACT_DIR}" -maxdepth 2 -type f | sort || true + } + + cleanup() { + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" down -v --remove-orphans >/dev/null 2>&1 || true + } + + trap cleanup EXIT + + if [ -z "${DEPLOYABLE_BACKEND_TAG_REF}" ] || [ -z "${DEPLOYABLE_BACKEND_DIGEST_REF}" ] || [ -z "${DEPLOYABLE_FRONTEND_TAG_REF}" ] || [ -z "${DEPLOYABLE_FRONTEND_DIGEST_REF}" ]; then + echo "❌ Missing deployable runtime image references from dispatch inputs" + exit 1 + fi + + echo "${{ secrets.PACKAGE_ACCESS_TOKEN }}" | docker login "http://${GITEA_REGISTRY}" -u "${{ github.actor }}" --password-stdin + docker pull "${CICD_IMAGE}" + docker pull "${DEPLOYABLE_BACKEND_TAG_REF}" + docker pull "${DEPLOYABLE_BACKEND_DIGEST_REF}" + docker pull "${DEPLOYABLE_FRONTEND_TAG_REF}" + docker pull "${DEPLOYABLE_FRONTEND_DIGEST_REF}" + + DEPLOYABLE_BACKEND_REPO="${DEPLOYABLE_BACKEND_DIGEST_REF%%@*}" + EXPECTED_BACKEND_DIGEST_REF="${DEPLOYABLE_BACKEND_DIGEST_REF}" + ACTUAL_BACKEND_TAG_DIGEST_REF="$({ + docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_BACKEND_TAG_REF}" \ + | grep "^${DEPLOYABLE_BACKEND_REPO}@sha256:" \ + | head -n 1 + } || true)" + + if [ -z "${ACTUAL_BACKEND_TAG_DIGEST_REF}" ]; then + echo "❌ Could not resolve backend digest from tag reference: ${DEPLOYABLE_BACKEND_TAG_REF}" + exit 1 + fi + + if [ "${ACTUAL_BACKEND_TAG_DIGEST_REF}" != "${EXPECTED_BACKEND_DIGEST_REF}" ]; then + echo "❌ Backend tag and digest mismatch" + echo "expected=${EXPECTED_BACKEND_DIGEST_REF}" + echo "actual=${ACTUAL_BACKEND_TAG_DIGEST_REF}" + exit 1 + fi + + DEPLOYABLE_FRONTEND_REPO="${DEPLOYABLE_FRONTEND_DIGEST_REF%%@*}" + EXPECTED_FRONTEND_DIGEST_REF="${DEPLOYABLE_FRONTEND_DIGEST_REF}" + ACTUAL_FRONTEND_TAG_DIGEST_REF="$({ + docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_FRONTEND_TAG_REF}" \ + | grep "^${DEPLOYABLE_FRONTEND_REPO}@sha256:" \ + | head -n 1 + } || true)" + + if [ -z "${ACTUAL_FRONTEND_TAG_DIGEST_REF}" ]; then + echo "❌ Could not resolve frontend digest from tag reference: ${DEPLOYABLE_FRONTEND_TAG_REF}" + exit 1 + fi + + if [ "${ACTUAL_FRONTEND_TAG_DIGEST_REF}" != "${EXPECTED_FRONTEND_DIGEST_REF}" ]; then + echo "❌ Frontend tag and digest mismatch" + echo "expected=${EXPECTED_FRONTEND_DIGEST_REF}" + echo "actual=${ACTUAL_FRONTEND_TAG_DIGEST_REF}" + exit 1 + fi + + echo "Resolved deployable backend digest: ${EXPECTED_BACKEND_DIGEST_REF}" + echo "Resolved deployable frontend digest: ${EXPECTED_FRONTEND_DIGEST_REF}" + + export DB_PASSWORD DB_URL CICD_IMAGE E2E_ARTIFACT_DIR="${ARTIFACT_DIR}" + export DEPLOYABLE_BACKEND_IMAGE="${EXPECTED_BACKEND_DIGEST_REF}" + export DEPLOYABLE_FRONTEND_IMAGE="${EXPECTED_FRONTEND_DIGEST_REF}" + + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d database backend frontend + + db_ready=false + for i in $(seq 1 30); do + if docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" exec -T database pg_isready -U plex_user -d plex_playlist >/dev/null 2>&1; then + db_ready=true + break + fi + sleep 2 + done + if [ "${db_ready}" != "true" ]; then + echo "❌ Database did not become ready" + dump_failure_context + exit 1 + fi + + backend_ready=false + for i in $(seq 1 40); do + backend_running_count="$(docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" ps --status running --services backend | wc -l)" + if [ "${backend_running_count}" -eq 0 ]; then + echo "❌ Backend container exited before becoming healthy" + dump_failure_context + exit 1 + fi + + health_code="$(fetch_http "backend" "/health" /tmp/e2e-backend-health.json)" + if [ "${health_code}" = "200" ]; then + backend_ready=true + break + fi + sleep 2 + done + if [ "${backend_ready}" != "true" ]; then + echo "❌ Backend did not become healthy" + cat /tmp/e2e-backend-health.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + root_code="$(fetch_http "backend" "/" /tmp/e2e-backend-root.json)" + if [ "${root_code}" != "200" ] || ! grep -q 'Plex Playlist Backend API' /tmp/e2e-backend-root.json; then + echo "❌ Backend root endpoint validation failed" + cat /tmp/e2e-backend-root.json 2>/dev/null || true + dump_failure_context + exit 1 + fi + + frontend_ready=false + for i in $(seq 1 30); do + frontend_running_count="$(docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" ps --status running --services frontend | wc -l)" + if [ "${frontend_running_count}" -gt 0 ]; then + frontend_root_code="$(fetch_url_from_backend "${FRONTEND_URL_RUNNER}" /tmp/e2e-frontend-root.html)" + if [ "${frontend_root_code}" = "200" ]; then + frontend_ready=true + break + fi + fi + sleep 2 + done + if [ "${frontend_ready}" != "true" ]; then + echo "❌ Frontend did not become reachable" + cat /tmp/e2e-frontend-root.html 2>/dev/null || true + dump_failure_context + exit 1 + fi + + docker compose -p "${COMPOSE_PROJECT_NAME}" -f "${COMPOSE_FILE}" run --rm e2e-tests \ + bash -c "cd /workspace/frontend && yarn test:e2e --reporter=list --timeout=90000" \ + 2>&1 | tee "${LOG_FILE}" + + TEST_STATUS=${PIPESTATUS[0]} + if [ "${TEST_STATUS}" -ne 0 ]; then + echo "❌ E2E tests failed (exit=${TEST_STATUS})" + echo "--- Last 200 lines of E2E output ---" + tail -n 200 "${LOG_FILE}" || true + echo "--- E2E artifact files ---" + find "${ARTIFACT_DIR}" -maxdepth 2 -type f | sort || true + dump_failure_context + exit "${TEST_STATUS}" + fi + + echo "=== E2E artifact files ===" + find "${ARTIFACT_DIR}" -maxdepth 2 -type f | sort || true + + - *failure_diagnostics_step diff --git a/.gitea/workflows/docker-build-base.yaml b/.gitea/workflows/docker-build-base.yaml deleted file mode 100644 index 35d1738..0000000 --- a/.gitea/workflows/docker-build-base.yaml +++ /dev/null @@ -1,537 +0,0 @@ -name: Docker Build Base - -on: - workflow_dispatch: - inputs: - force_rebuild: - description: Force a rebuild even when the immutable base tag already exists - required: false - default: "false" - head_sha: - description: Commit SHA to process - required: false - source_workflow: - description: Upstream workflow name - required: false - trace_id: - description: Correlation id propagated across CICD dispatch chain - required: false - -concurrency: - group: base-${{ github.sha }} - cancel-in-progress: true - -env: - GITEA_SSH_HOST: kankali.darkhelm.lan - GITEA_SSH_PORT: "2222" - GITEA_REPO_SSH_URL: ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git - GITEA_REGISTRY: kankali.darkhelm.lan:3001 - GITEA_REGISTRY_IP: 10.18.75.2 - GITEA_REGISTRY_HOST: kankali.darkhelm.lan - -defaults: - run: - shell: bash - -jobs: - publish-base: - name: Build and Publish CICD Base Image - # Use ubuntu-act runner pool which is known to be available. - runs-on: ubuntu-act - timeout-minutes: 35 - outputs: - base_hash: ${{ steps.base-state.outputs.base_hash }} - head_sha: ${{ steps.meta.outputs.head_sha }} - - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Emit startup diagnostics - shell: sh - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - TARGET_LABEL: ubuntu-act - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - TRACE_SUFFIX="$(printf '%s' "${RESOLVED_HEAD_SHA}" | cut -c1-8)" - TRACE_ID="${TRACE_ID_INPUT:-cicd-base-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${TRACE_SUFFIX}}" - echo "=== Base Workflow Startup Audit ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "force_rebuild=${FORCE_REBUILD}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - echo "target_runner_label=${TARGET_LABEL}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "startup_audit=ok" - - - name: Preflight Docker and registry connectivity - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - run: | - set -e - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - echo "=== Docker Preflight ===" - command -v docker - docker --version - docker info >/tmp/docker-info.log 2>&1 || (echo "❌ docker info failed" && tail -n 40 /tmp/docker-info.log && exit 1) - grep -qi "${GITEA_REGISTRY}" /tmp/docker-info.log || echo "⚠ Registry not listed in docker info insecure registries: ${GITEA_REGISTRY}" - - echo "=== Registry Login Preflight ===" - if [ -z "${PACKAGE_ACCESS_TOKEN}" ]; then - echo "❌ PACKAGE_ACCESS_TOKEN is empty" - exit 1 - fi - echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin >/tmp/docker-login.log 2>&1 || (echo "❌ Registry login preflight failed" && tail -n 40 /tmp/docker-login.log && exit 1) - echo "registry_login_preflight=ok" - - - name: Audit trigger context - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-base-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESOLVED_HEAD_SHA:0:8}}" - echo "=== Dispatch Audit: CICD Base Image ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "force_rebuild=${FORCE_REBUILD}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - - - name: Resolve head SHA - id: meta - env: - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" - - - name: Minimal checkout for base inputs - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - GITHUB_SHA: ${{ steps.meta.outputs.head_sha }} - run: | - umask 077 - trap 'rm -f ~/.ssh/id_rsa' EXIT - echo "=== Minimal Repository Checkout for Base Inputs ===" - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - if [ -n "${SSH_PRIVATE_KEY}" ]; then - mkdir -p ~/.ssh - echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - echo "Loaded SSH key fingerprint: $(ssh-keygen -lf ~/.ssh/id_rsa | awk '{print $2}')" - ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - else - echo "❌ SSH_PRIVATE_KEY is empty" - exit 1 - fi - - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git clone --depth 1 --no-checkout \ - "${GITEA_REPO_SSH_URL}" . - - if [ -n "${GITHUB_SHA}" ] && \ - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git fetch --depth 1 origin "${GITHUB_SHA}" >/dev/null 2>&1; then - git checkout FETCH_HEAD -- Dockerfile.cicd-base .dockerignore scripts/compute-cicd-base-hash.sh - echo "✓ Checked out base inputs from commit ${GITHUB_SHA}" - else - git checkout HEAD -- Dockerfile.cicd-base .dockerignore scripts/compute-cicd-base-hash.sh - echo "⚠ Falling back to default branch HEAD for base inputs checkout" - fi - - chmod +x scripts/compute-cicd-base-hash.sh - rm -f ~/.ssh/id_rsa - - - name: Compute base hash and inspect registry - id: base-state - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }} - run: | - echo "=== Computing CICD Base Image Hash ===" - - BASE_HASH=$(./scripts/compute-cicd-base-hash.sh) - BASE_REF_HASH="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:${BASE_HASH}" - BASE_REF_LATEST="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:latest" - - echo "Base hash: ${BASE_HASH}" - echo "base_hash=${BASE_HASH}" >> $GITHUB_OUTPUT - echo "base_ref_hash=${BASE_REF_HASH}" >> $GITHUB_OUTPUT - echo "base_ref_latest=${BASE_REF_LATEST}" >> $GITHUB_OUTPUT - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - if ! echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin >/dev/null 2>&1; then - echo "❌ Registry login failed during base inspection" - exit 1 - fi - - if [ "${FORCE_REBUILD}" = "true" ]; then - echo "Manual force rebuild requested" - echo "needs_build=true" >> $GITHUB_OUTPUT - exit 0 - fi - - for i in 1 2 3; do - echo "Existing base check ${i}/3 for ${BASE_REF_HASH}..." - # Manifest inspect is unreliable on some runners; use pull as truth. - if timeout 900 docker pull "${BASE_REF_HASH}" >/tmp/base-state-pull.log 2>&1; then - echo "✓ Immutable base image already exists and is pullable: ${BASE_REF_HASH}" - echo "needs_build=false" >> $GITHUB_OUTPUT - exit 0 - else - pull_exit=$? - echo "Existing base pull check failed with exit code ${pull_exit}" - if [ "${pull_exit}" -eq 124 ]; then - echo "Existing base pull timed out after 900s" - fi - if [ -s /tmp/base-state-pull.log ]; then - tail -n 20 /tmp/base-state-pull.log || true - fi - fi - - if [ "${i}" -lt 3 ]; then - sleep 10 - fi - done - - echo "ℹ Immutable base image missing or not yet pullable: ${BASE_REF_HASH}" - echo "needs_build=true" >> $GITHUB_OUTPUT - - - name: Write registry push helpers - run: | - cat > /tmp/registry-push-helpers.sh <<'EOF' - #!/bin/bash - - ensure_skopeo() { - if command -v skopeo >/dev/null 2>&1; then - return 0 - fi - - if command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y skopeo - elif command -v apk >/dev/null 2>&1; then - apk add --no-cache skopeo - fi - - if ! command -v skopeo >/dev/null 2>&1; then - echo "❌ skopeo not available for fallback push" - return 1 - fi - } - - push_ref_with_fallback() { - ref="$1" - local_image="$2" - archive_path="$3" - registry_user="$4" - package_access_token="$5" - - for i in 1 2 3; do - echo "Docker push attempt ${i}/3 for ${ref}..." - if docker push "${ref}"; then - echo "✓ Docker push succeeded for ${ref}" - return 0 - fi - - if [ "${i}" -lt 3 ]; then - sleep 10 - fi - done - - echo "⚠ Docker push failed for ${ref}; trying skopeo fallback" - - if ! ensure_skopeo; then - return 1 - fi - - if [ ! -f "${archive_path}" ]; then - echo "Creating local image archive for fallback push..." - if ! docker save "${local_image}" -o "${archive_path}"; then - echo "❌ Failed to create docker archive for fallback push" - return 1 - fi - fi - - if skopeo copy \ - --dest-creds "${registry_user}:${package_access_token}" \ - --dest-tls-verify=false \ - "docker-archive:${archive_path}" \ - "docker://${ref}"; then - echo "✓ Skopeo fallback push succeeded for ${ref}" - return 0 - fi - - echo "❌ Skopeo fallback push failed for ${ref}" - return 1 - } - EOF - - chmod 700 /tmp/registry-push-helpers.sh - - - name: Build and push base image - if: steps.base-state.outputs.needs_build == 'true' - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - BASE_HASH: ${{ steps.base-state.outputs.base_hash }} - BASE_REF_HASH: ${{ steps.base-state.outputs.base_ref_hash }} - BASE_REF_LATEST: ${{ steps.base-state.outputs.base_ref_latest }} - run: | - echo "=== Building CICD Base Image ===" - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - if ! echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin; then - echo "❌ Registry login failed before build" - exit 1 - fi - - PLAYWRIGHT_BROWSERS_MIRROR_TAG="${GITEA_REGISTRY}/darkhelm.org/playwright-browsers:v1.56.1-jammy" - echo "playwright_browsers_mirror_tag=${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" - - if ! docker pull "${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" >/tmp/playwright-mirror-pull.log 2>&1; then - echo "❌ Required internal Playwright browsers mirror image is missing: ${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" - echo "Mirror this image into your internal registry before running base builds." - tail -n 80 /tmp/playwright-mirror-pull.log || true - exit 1 - fi - - PLAYWRIGHT_BROWSERS_IMAGE=$(docker image inspect --format='{{index .RepoDigests 0}}' "${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" 2>/dev/null || true) - if [ -z "${PLAYWRIGHT_BROWSERS_IMAGE}" ]; then - echo "❌ Failed to resolve immutable digest for ${PLAYWRIGHT_BROWSERS_MIRROR_TAG}" - exit 1 - fi - - echo "playwright_browsers_image=${PLAYWRIGHT_BROWSERS_IMAGE}" - - export DOCKER_BUILDKIT=1 - - BUILD_TIMEOUT_SECONDS=5400 - BUILD_START_EPOCH=$(date +%s) - echo "docker_build_timeout_seconds=${BUILD_TIMEOUT_SECONDS}" - echo "docker_build_started_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - timeout "${BUILD_TIMEOUT_SECONDS}" docker build --progress=plain -f Dockerfile.cicd-base \ - --build-arg BASE_IMAGE_VERSION="v1.0.0-${BASE_HASH}" \ - --build-arg BASE_IMAGE_HASH="${BASE_HASH}" \ - --build-arg PLAYWRIGHT_BROWSERS_IMAGE="${PLAYWRIGHT_BROWSERS_IMAGE}" \ - -t cicd-base:latest . - BUILD_EXIT_CODE=$? - if [ "${BUILD_EXIT_CODE}" -ne 0 ]; then - if [ "${BUILD_EXIT_CODE}" -eq 124 ]; then - echo "❌ docker build timed out after ${BUILD_TIMEOUT_SECONDS}s" - else - echo "❌ docker build failed with exit code ${BUILD_EXIT_CODE}" - fi - exit "${BUILD_EXIT_CODE}" - fi - - BUILD_END_EPOCH=$(date +%s) - BUILD_ELAPSED=$((BUILD_END_EPOCH - BUILD_START_EPOCH)) - echo "docker_build_elapsed_seconds=${BUILD_ELAPSED}" - - ARCHIVE_PATH="/tmp/cicd-base.tar" - source /tmp/registry-push-helpers.sh - - docker tag cicd-base:latest "${BASE_REF_HASH}" - docker tag cicd-base:latest "${BASE_REF_LATEST}" - - push_ref_with_fallback "${BASE_REF_HASH}" "cicd-base:latest" "${ARCHIVE_PATH}" "${REGISTRY_USER}" "${PACKAGE_ACCESS_TOKEN}" - push_ref_with_fallback "${BASE_REF_LATEST}" "cicd-base:latest" "${ARCHIVE_PATH}" "${REGISTRY_USER}" "${PACKAGE_ACCESS_TOKEN}" - - - name: Verify published base image - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - BASE_REF_HASH: ${{ steps.base-state.outputs.base_ref_hash }} - run: | - echo "=== Verifying Published CICD Base Image ===" - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - if ! echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin; then - echo "❌ Registry login failed during verification" - exit 1 - fi - - for i in 1 2 3 4 5 6; do - echo "Verification attempt ${i}/6 for ${BASE_REF_HASH}..." - - if docker pull "${BASE_REF_HASH}"; then - echo "✓ Published base image is pullable: ${BASE_REF_HASH}" - exit 0 - fi - - if [ "${i}" -lt 6 ]; then - echo "⚠ Pull failed; waiting 20s before retry" - sleep 20 - fi - done - - echo "❌ Published base image could not be pulled after 6 attempts: ${BASE_REF_HASH}" - exit 1 - - - name: Dispatch main build workflow - env: - ACTIONS_TRIGGER_TOKEN: ${{ secrets.ACTIONS_TRIGGER_TOKEN }} - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - HEAD_SHA: ${{ steps.meta.outputs.head_sha }} - BASE_HASH: ${{ steps.base-state.outputs.base_hash }} - REPO_FULL: ${{ github.repository }} - HEAD_REF: ${{ github.head_ref }} - REF_NAME: ${{ github.ref_name }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - set -e - - DISPATCH_TOKEN="${ACTIONS_TRIGGER_TOKEN:-${PACKAGE_ACCESS_TOKEN:-}}" - - if [ -z "${DISPATCH_TOKEN}" ]; then - echo "❌ Missing dispatch token. Set ACTIONS_TRIGGER_TOKEN (repo write scope) or ensure PACKAGE_ACCESS_TOKEN has Actions workflow-dispatch permissions." - exit 1 - fi - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - REPO_OWNER="${REPO_FULL%/*}" - REPO_NAME="${REPO_FULL#*/}" - TARGET_REF="${HEAD_REF:-${REF_NAME}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-base-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${HEAD_SHA:0:8}}" - - echo "trace_id=${TRACE_ID}" - echo "target_ref=${TARGET_REF}" - - CANDIDATE_API_BASES=() - if [ -n "${GITHUB_SERVER_URL:-}" ]; then - CANDIDATE_API_BASES+=("${GITHUB_SERVER_URL%/}/api/v1") - fi - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_IP}:3001/api/v1") - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_HOST}:3001/api/v1") - - if ! command -v curl >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - - HELPER_PATH="/tmp/dispatch-workflow.sh" - - fetch_dispatch_helper() { - local helper_ref="$1" - local api_base - for api_base in "${CANDIDATE_API_BASES[@]}"; do - helper_url="${api_base}/repos/${REPO_OWNER}/${REPO_NAME}/raw/scripts/dispatch-workflow.sh?ref=${helper_ref}" - if curl -fsS --connect-timeout 5 --max-time 20 \ - -H "Authorization: token ${DISPATCH_TOKEN}" \ - -H "User-Agent: plex-playlist-cicd-base" \ - -o "${HELPER_PATH}" \ - "${helper_url}"; then - chmod +x "${HELPER_PATH}" - return 0 - fi - done - return 1 - } - - if ! fetch_dispatch_helper "${TARGET_REF}" && ! fetch_dispatch_helper "${HEAD_SHA}"; then - echo "❌ Failed to fetch scripts/dispatch-workflow.sh from repository" - exit 1 - fi - - DISPATCH_ARGS=( - --token "${DISPATCH_TOKEN}" - --repo "${REPO_FULL}" - --workflow "docker-build-main.yaml" - --ref "${TARGET_REF}" - --head-sha "${HEAD_SHA}" - --source-workflow "CICD Base Image" - --trace-id "${TRACE_ID}" - --base-needed "true" - --base-hash "${BASE_HASH}" - ) - - for API_BASE in "${CANDIDATE_API_BASES[@]}"; do - DISPATCH_ARGS+=(--api-base "${API_BASE}") - done - - "${HELPER_PATH}" "${DISPATCH_ARGS[@]}" - - - name: Failure diagnostics - if: failure() - run: | - echo "=== Failure Diagnostics ===" - date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' - echo "runner_name=${RUNNER_NAME:-unknown}" - echo "runner_hostname=${HOSTNAME:-unknown}" - uname -a || true - cat /etc/os-release 2>/dev/null || true - df -h || true - free -h || true - ps aux --sort=-%mem | head -n 30 || true - - if command -v docker >/dev/null 2>&1; then - echo "=== Docker Diagnostics ===" - docker version || true - docker info || true - docker ps -a || true - docker images --digests | head -n 50 || true - else - echo "docker not available on this runner" - fi - - echo "=== Kernel Tail ===" - dmesg | tail -n 120 || true diff --git a/.gitea/workflows/docker-build-main.yaml b/.gitea/workflows/docker-build-main.yaml deleted file mode 100644 index bdf21c2..0000000 --- a/.gitea/workflows/docker-build-main.yaml +++ /dev/null @@ -1,481 +0,0 @@ -name: Docker Build Main - -on: - workflow_dispatch: - inputs: - head_sha: - description: Commit SHA to process - required: false - base_hash: - description: Immutable base hash to use for CICD base image - required: false - source_workflow: - description: Upstream workflow name - required: false - base_needed: - description: Whether base rebuild was required - required: false - trace_id: - description: Correlation id propagated across CICD dispatch chain - required: false - -env: - GITEA_SSH_HOST: kankali.darkhelm.lan - GITEA_SSH_PORT: "2222" - GITEA_REPO_SSH_URL: ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git - GITEA_REGISTRY: kankali.darkhelm.lan:3001 - GITEA_REGISTRY_IP: 10.18.75.2 - GITEA_REGISTRY_HOST: kankali.darkhelm.lan - -defaults: - run: - shell: bash - -concurrency: - group: main-build-${{ github.sha }} - cancel-in-progress: true - -jobs: - startup-audit: - name: Main Workflow Startup Audit - # Pin startup audit to high-memory worker to avoid setup-stage runner churn. - runs-on: ubuntu-act-8gb - timeout-minutes: 5 - steps: - - name: Identify runner - shell: sh - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Emit startup diagnostics - shell: sh - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - BASE_NEEDED: ${{ github.event.inputs.base_needed }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - TARGET_LABEL: ubuntu-act-8gb - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - TRACE_SUFFIX="$(printf '%s' "${RESOLVED_HEAD_SHA}" | cut -c1-8)" - TRACE_ID="${TRACE_ID_INPUT:-cicd-main-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${TRACE_SUFFIX}}" - echo "=== Main Workflow Startup Audit ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "base_needed=${BASE_NEEDED}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "base_hash_input=${BASE_HASH_INPUT}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - echo "target_runner_label=${TARGET_LABEL}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "startup_audit=ok" - - - name: Check control-plane reachability - shell: sh - run: | - SERVER_URL="${GITHUB_SERVER_URL:-}" - if command -v curl >/dev/null 2>&1; then - if [ -n "${SERVER_URL}" ] && curl -fsS --connect-timeout 5 --max-time 10 "${SERVER_URL%/}/api/v1/version" >/tmp/gitea-version.json 2>/dev/null; then - echo "gitea_api_reachable=true" - cat /tmp/gitea-version.json || true - else - echo "gitea_api_reachable=false" - fi - else - echo "curl unavailable; skipping reachability check" - fi - - - &failure_diagnostics_step - name: Failure diagnostics - if: failure() - run: | - echo "=== Failure Diagnostics ===" - date -u '+timestamp_utc=%Y-%m-%dT%H:%M:%SZ' - echo "runner_name=${RUNNER_NAME:-unknown}" - echo "runner_hostname=${HOSTNAME:-unknown}" - uname -a || true - cat /etc/os-release 2>/dev/null || true - df -h || true - free -h || true - ps aux --sort=-%mem | head -n 30 || true - - if command -v docker >/dev/null 2>&1; then - echo "=== Docker Diagnostics ===" - docker version || true - docker info || true - docker ps -a || true - docker images --digests | head -n 50 || true - else - echo "docker not available on this runner" - fi - - echo "=== Kernel Tail ===" - dmesg | tail -n 120 || true - - build: - name: Build and Push CICD Complete Image - # Pin main image build to high-memory worker to reduce setup-time failures. - runs-on: ubuntu-act-8gb - needs: startup-audit - timeout-minutes: 60 - outputs: - head_sha: ${{ steps.meta.outputs.head_sha }} - deployable_backend_tag_ref: ${{ steps.deployable_backend_ref.outputs.deployable_backend_tag_ref }} - deployable_backend_digest_ref: ${{ steps.deployable_backend_ref.outputs.deployable_backend_digest_ref }} - - steps: - - name: Identify runner - run: | - echo "=== Runner Identity ===" - echo "runner_name=${RUNNER_NAME:-}" - echo "runner_name_hint=${GITEA_RUNNER_NAME:-${ACT_RUNNER_NAME:-${RUNNER_NAME:-unknown}}}" - echo "runner_hostname_env=${HOSTNAME:-unknown}" - echo "runner_uname_n=$(uname -n 2>/dev/null || echo unknown)" - echo "runner_etc_hostname=$(cat /etc/hostname 2>/dev/null || echo unknown)" - echo "runner_os=${RUNNER_OS:-unknown}" - echo "runner_arch=${RUNNER_ARCH:-unknown}" - echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - - name: Audit trigger context - env: - EVENT_NAME: ${{ github.event_name }} - SOURCE_WORKFLOW: ${{ github.event.inputs.source_workflow }} - BASE_NEEDED: ${{ github.event.inputs.base_needed }} - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - HEAD_REF: ${{ github.head_ref }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-main-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RESOLVED_HEAD_SHA:0:8}}" - echo "=== Dispatch Audit: CICD Main Build ===" - echo "event_name=${EVENT_NAME}" - echo "source_workflow=${SOURCE_WORKFLOW}" - echo "base_needed=${BASE_NEEDED}" - echo "head_sha_input=${HEAD_SHA_INPUT}" - echo "head_sha=${RESOLVED_HEAD_SHA}" - echo "base_hash_input=${BASE_HASH_INPUT}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "head_ref=${HEAD_REF}" - echo "trace_id=${TRACE_ID}" - - - name: Resolve head SHA - id: meta - env: - HEAD_SHA_INPUT: ${{ github.event.inputs.head_sha }} - HEAD_SHA_FALLBACK: ${{ github.sha }} - run: | - RESOLVED_HEAD_SHA="${HEAD_SHA_INPUT:-${HEAD_SHA_FALLBACK}}" - echo "head_sha=${RESOLVED_HEAD_SHA}" >> "$GITHUB_OUTPUT" - - - name: Minimal checkout for build and verification inputs - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - HEAD_SHA: ${{ steps.meta.outputs.head_sha }} - run: | - set -e - umask 077 - trap 'rm -f ~/.ssh/id_rsa' EXIT - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - mkdir -p ~/.ssh - echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -p "${GITEA_SSH_PORT}" "${GITEA_SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git clone --depth 1 --no-checkout "${GITEA_REPO_SSH_URL}" . - - GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o StrictHostKeyChecking=no" \ - git fetch --depth 1 origin "${HEAD_SHA}" >/dev/null 2>&1 || true - - git checkout FETCH_HEAD -- \ - .dockerignore \ - Dockerfile.backend \ - Dockerfile.frontend \ - Dockerfile.cicd \ - Dockerfile.cicd-base \ - backend \ - frontend \ - scripts/compute-cicd-base-hash.sh \ - scripts/check-dockerfile-boundaries.sh \ - scripts/verify-deployable-image-purity.sh - chmod +x scripts/compute-cicd-base-hash.sh - - - name: Verify deployable runtime boundaries - run: | - set -e - bash ./scripts/check-dockerfile-boundaries.sh - - - name: Build and verify deployable runtime image purity - env: - HEAD_SHA: ${{ steps.meta.outputs.head_sha }} - run: | - set -e - - docker build -f Dockerfile.backend \ - -t deployable-backend:"${HEAD_SHA}" . - - docker build -f Dockerfile.frontend \ - --target production \ - -t deployable-frontend:"${HEAD_SHA}" . - - bash ./scripts/verify-deployable-image-purity.sh \ - --image deployable-backend:"${HEAD_SHA}" \ - --profile backend - - bash ./scripts/verify-deployable-image-purity.sh \ - --image deployable-frontend:"${HEAD_SHA}" \ - --profile frontend - - - name: Push deployable backend runtime image - id: deployable_backend_ref - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - HEAD_SHA: ${{ steps.meta.outputs.head_sha }} - run: | - set -euo pipefail - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin - - DEPLOYABLE_BACKEND_REPO="${GITEA_REGISTRY}/darkhelm.org/deployable-backend" - DEPLOYABLE_BACKEND_TAG_REF="${DEPLOYABLE_BACKEND_REPO}:${HEAD_SHA}" - - docker tag "deployable-backend:${HEAD_SHA}" "${DEPLOYABLE_BACKEND_TAG_REF}" - docker push "${DEPLOYABLE_BACKEND_TAG_REF}" - docker pull "${DEPLOYABLE_BACKEND_TAG_REF}" >/dev/null - - DEPLOYABLE_BACKEND_DIGEST_REF="$({ - docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${DEPLOYABLE_BACKEND_TAG_REF}" \ - | grep "^${DEPLOYABLE_BACKEND_REPO}@sha256:" \ - | head -n 1 - } || true)" - - if [ -z "${DEPLOYABLE_BACKEND_DIGEST_REF}" ]; then - echo "❌ Unable to resolve deployable backend digest reference" - exit 1 - fi - - echo "deployable_backend_tag_ref=${DEPLOYABLE_BACKEND_TAG_REF}" >> "$GITHUB_OUTPUT" - echo "deployable_backend_digest_ref=${DEPLOYABLE_BACKEND_DIGEST_REF}" >> "$GITHUB_OUTPUT" - echo "deployable_backend_tag_ref=${DEPLOYABLE_BACKEND_TAG_REF}" - echo "deployable_backend_digest_ref=${DEPLOYABLE_BACKEND_DIGEST_REF}" - - - name: Build and push complete CICD image - env: - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - REGISTRY_USER: ${{ github.actor }} - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - HEAD_SHA: ${{ steps.meta.outputs.head_sha }} - BASE_HASH_INPUT: ${{ github.event.inputs.base_hash }} - run: | - set -e - umask 077 - trap 'rm -f /tmp/ssh_key' EXIT - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - echo "${PACKAGE_ACCESS_TOKEN}" | docker login "http://${GITEA_REGISTRY}" -u "${REGISTRY_USER}" --password-stdin - - if [ -n "${BASE_HASH_INPUT}" ]; then - BASE_HASH="${BASE_HASH_INPUT}" - echo "Using provided immutable base hash from upstream workflow dispatch: ${BASE_HASH}" - else - BASE_HASH=$(./scripts/compute-cicd-base-hash.sh) - echo "No base_hash input provided; computed base hash locally: ${BASE_HASH}" - fi - - BASE_IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd-base:${BASE_HASH}" - - verify_base_image() { - # Fast path: base image already present locally. - if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then - return 0 - fi - - # Manifest inspect unreliable on these runners; use pull as truth. - timeout 1800 docker pull "${BASE_IMAGE}" >/tmp/base-image-pull.log 2>&1 - } - - max_attempts=2 - sleep_seconds=15 - for i in $(seq 1 "${max_attempts}"); do - echo "Base availability check ${i}/${max_attempts}..." - if verify_base_image; then - echo "✓ Base image available and hash matched: ${BASE_IMAGE}" - break - else - pull_exit=$? - echo "Base pull attempt ${i} failed with exit code ${pull_exit}" - if [ "${pull_exit}" -eq 124 ]; then - echo "Pull timed out after 1800s while downloading base image" - fi - if [ -s /tmp/base-image-pull.log ]; then - tail -n 40 /tmp/base-image-pull.log || true - fi - - if [ "${i}" -eq "${max_attempts}" ]; then - echo "❌ Required immutable base image is not available or mismatched: ${BASE_IMAGE}" - exit 1 - fi - sleep "${sleep_seconds}" - fi - done - - echo "✓ Base image ready: ${BASE_IMAGE}" - - echo "${SSH_PRIVATE_KEY}" > /tmp/ssh_key - chmod 600 /tmp/ssh_key - export DOCKER_BUILDKIT=1 - - docker build -f Dockerfile.cicd \ - --secret id=ssh_private_key,src=/tmp/ssh_key \ - --add-host "${GITEA_SSH_HOST}:${GITEA_REGISTRY_IP}" \ - --build-arg GITHUB_SHA="${HEAD_SHA}" \ - --build-arg CICD_BASE_IMAGE="${BASE_IMAGE}" \ - -t cicd:latest . - - docker tag cicd:latest "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:latest" - docker tag cicd:latest "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" - - docker push "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:latest" - docker push "${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" - - - *failure_diagnostics_step - - dispatch-tests: - name: Dispatch CICD Tests - runs-on: ubuntu-act - timeout-minutes: 15 - needs: build - if: needs.build.result == 'success' - steps: - - name: Dispatch tests workflow - env: - ACTIONS_TRIGGER_TOKEN: ${{ secrets.ACTIONS_TRIGGER_TOKEN }} - PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }} - HEAD_SHA: ${{ needs.build.outputs.head_sha }} - DEPLOYABLE_BACKEND_TAG_REF: ${{ needs.build.outputs.deployable_backend_tag_ref }} - DEPLOYABLE_BACKEND_DIGEST_REF: ${{ needs.build.outputs.deployable_backend_digest_ref }} - REPO_FULL: ${{ github.repository }} - HEAD_REF: ${{ github.head_ref }} - REF_NAME: ${{ github.ref_name }} - TRACE_ID_INPUT: ${{ github.event.inputs.trace_id }} - run: | - set -e - - DISPATCH_TOKEN="${ACTIONS_TRIGGER_TOKEN:-${PACKAGE_ACCESS_TOKEN:-}}" - - if [ -z "${DISPATCH_TOKEN}" ]; then - echo "❌ Missing dispatch token. Set ACTIONS_TRIGGER_TOKEN (repo write scope) or ensure PACKAGE_ACCESS_TOKEN has Actions workflow-dispatch permissions." - exit 1 - fi - - if ! grep -q "${GITEA_REGISTRY_HOST}" /etc/hosts; then - echo "${GITEA_REGISTRY_IP} ${GITEA_REGISTRY_HOST}" >> /etc/hosts - fi - - REPO_OWNER="${REPO_FULL%/*}" - REPO_NAME="${REPO_FULL#*/}" - TARGET_REF="${HEAD_REF:-${REF_NAME}}" - TRACE_ID="${TRACE_ID_INPUT:-cicd-main-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${HEAD_SHA:0:8}}" - - echo "trace_id=${TRACE_ID}" - echo "target_ref=${TARGET_REF}" - - CANDIDATE_API_BASES=() - if [ -n "${GITHUB_SERVER_URL:-}" ]; then - CANDIDATE_API_BASES+=("${GITHUB_SERVER_URL%/}/api/v1") - fi - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_IP}:3001/api/v1") - CANDIDATE_API_BASES+=("http://${GITEA_REGISTRY_HOST}:3001/api/v1") - - ensure_curl() { - if command -v curl >/dev/null 2>&1; then - return 0 - fi - if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq curl ca-certificates - fi - command -v curl >/dev/null 2>&1 - } - - ensure_curl || { echo "❌ curl unavailable for dispatch"; exit 1; } - - HELPER_PATH="/tmp/dispatch-workflow.sh" - - fetch_dispatch_helper() { - local helper_ref="$1" - local api_base - for api_base in "${CANDIDATE_API_BASES[@]}"; do - helper_url="${api_base}/repos/${REPO_OWNER}/${REPO_NAME}/raw/scripts/dispatch-workflow.sh?ref=${helper_ref}" - if curl -fsS --connect-timeout 5 --max-time 20 \ - -H "Authorization: token ${DISPATCH_TOKEN}" \ - -H "User-Agent: plex-playlist-cicd-main" \ - -o "${HELPER_PATH}" \ - "${helper_url}"; then - chmod +x "${HELPER_PATH}" - return 0 - fi - done - return 1 - } - - if ! fetch_dispatch_helper "${TARGET_REF}" && ! fetch_dispatch_helper "${HEAD_SHA}"; then - echo "❌ Failed to fetch scripts/dispatch-workflow.sh from repository" - exit 1 - fi - - DISPATCH_ARGS=( - --token "${DISPATCH_TOKEN}" - --repo "${REPO_FULL}" - --workflow "cicd-tests.yaml" - --ref "${TARGET_REF}" - --head-sha "${HEAD_SHA}" - --source-workflow "CICD Main Build" - --trace-id "${TRACE_ID}" - --input "deployable_backend_tag_ref=${DEPLOYABLE_BACKEND_TAG_REF}" - --input "deployable_backend_digest_ref=${DEPLOYABLE_BACKEND_DIGEST_REF}" - ) - - for API_BASE in "${CANDIDATE_API_BASES[@]}"; do - DISPATCH_ARGS+=(--api-base "${API_BASE}") - done - - "${HELPER_PATH}" "${DISPATCH_ARGS[@]}" - - - *failure_diagnostics_step diff --git a/Dockerfile.backend b/Dockerfile.backend index fdc12fa..12a6015 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,37 +1,30 @@ # Backend Dockerfile for FastAPI with Python 3.14 -FROM python:3.14-slim +FROM python:3.14-slim AS dependencies -# Set working directory WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Install uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv -# Create and activate virtual environment ENV VIRTUAL_ENV=/app/.venv -RUN uv venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -# Copy dependency files first for better caching COPY backend/pyproject.toml backend/uv.lock* ./ # Hatchling resolves the readme path ../README.md relative to the backend # package root (/app). Create a stub so metadata validation succeeds without # requiring the file to pass through .dockerignore. RUN echo '# plex-playlist' > /README.md +RUN uv venv "$VIRTUAL_ENV" && uv sync --frozen --no-dev -# Install runtime dependencies only -RUN uv sync --frozen --no-dev +FROM python:3.14-slim AS runtime -# Copy application code -COPY backend/ . +WORKDIR /app + +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +COPY --from=dependencies /app/.venv /app/.venv +COPY backend/src ./src -# Expose port EXPOSE 8000 -# Default command - can be overridden in compose for development CMD ["uvicorn", "backend.main:app", "--app-dir", "/app/src", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend index be5d130..94897d0 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,40 +1,20 @@ # Frontend Dockerfile for Vue/Vite TypeScript project -FROM node:20-alpine AS base +FROM node:24-alpine AS build -# Set working directory WORKDIR /app -# Copy package files first for better caching -COPY frontend/package*.json ./ -COPY frontend/.yarnrc.yml ./ -COPY frontend/yarn.lock* frontend/pnpm-lock.yaml* ./ +COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml ./ +COPY frontend/.yarn/ ./.yarn/ +RUN corepack enable && yarn install --immutable -# Install dependencies -RUN if [ -f yarn.lock ]; then corepack enable && corepack yarn install; \ - elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install; \ - else npm install; fi - -# Copy application code COPY frontend/ . +RUN yarn build -# Development stage -FROM base AS development -EXPOSE 5173 -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] - -# Production build stage -FROM base AS build -RUN if [ -f yarn.lock ]; then corepack yarn build; \ - elif [ -f pnpm-lock.yaml ]; then pnpm build; \ - else npm run build; fi - -# Production stage FROM nginx:alpine AS production -# Copy built assets from build stage + COPY --from=build /app/dist /usr/share/nginx/html -# Copy nginx configuration COPY frontend/nginx.conf /etc/nginx/nginx.conf -# Expose port + EXPOSE 80 -# Start nginx + CMD ["nginx", "-g", "daemon off;"] diff --git a/backend/tests/integration/test_runtime_blackbox.py b/backend/tests/integration/test_runtime_blackbox.py new file mode 100644 index 0000000..4d75810 --- /dev/null +++ b/backend/tests/integration/test_runtime_blackbox.py @@ -0,0 +1,55 @@ +"""Runtime black-box integration tests executed over the container network.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + +import pytest + +BACKEND_BASE_URL = os.getenv("INTEGRATION_BACKEND_URL", "http://backend:8000").rstrip( + "/" +) + + +def _fetch(path: str) -> tuple[int, str]: + """Fetch an endpoint and return status code and body text.""" + url = f"{BACKEND_BASE_URL}{path}" + request = urllib.request.Request(url=url, method="GET") + try: + with urllib.request.urlopen(request, timeout=5) as response: + body = response.read().decode("utf-8", errors="replace") + return response.status, body + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return exc.code, body + + +@pytest.mark.integration +def test_root_endpoint_runtime() -> None: + """Runtime root endpoint should return backend API message.""" + status, body = _fetch("/") + assert status == 200 + payload = json.loads(body) + assert payload == {"message": "Plex Playlist Backend API"} + + +@pytest.mark.integration +def test_health_endpoint_runtime() -> None: + """Runtime health endpoint should report healthy database connectivity.""" + status, body = _fetch("/health") + assert status == 200 + payload = json.loads(body) + assert payload.get("status") == "healthy" + assert payload.get("database") == "connected" + + +@pytest.mark.integration +def test_compatibility_endpoint_runtime() -> None: + """Runtime compatibility endpoint should report policy check success.""" + status, body = _fetch("/compatibility") + assert status == 200 + payload = json.loads(body) + assert payload.get("ok") is True diff --git a/docs/CICD_MULTI_STAGE_BUILD.md b/docs/CICD_MULTI_STAGE_BUILD.md index 9f1691d..6092969 100644 --- a/docs/CICD_MULTI_STAGE_BUILD.md +++ b/docs/CICD_MULTI_STAGE_BUILD.md @@ -142,14 +142,10 @@ jobs: ### Responsibility Split -- `.gitea/workflows/cicd-start.yaml` owns startup routing and trace propagation. -- `.gitea/workflows/cicd-source-checks.yaml` owns early source-level - format/lint/type gating before any promotion dispatch. -- `.gitea/workflows/docker-build-base.yaml` owns base publication and verification. -- `.gitea/workflows/docker-build-main.yaml` owns complete-image publication. -- `.gitea/workflows/cicd-checks.yaml` and `.gitea/workflows/cicd-tests.yaml` - own post-build CI validation and tests. -- Main CI never rebuilds the base image locally. +- `.gitea/workflows/cicd.yaml` owns the full CI pipeline: base image publish, + CICD image publish, source checks, unit tests, runtime image publication, + integration tests, and E2E tests. +- The older split workflows remain only as deprecated/manual helper paths. ### Runtime Boundary Enforcement @@ -173,7 +169,7 @@ and deployable runtime artifacts before publishing the complete CICD image. Workflow location: -- `.gitea/workflows/docker-build-main.yaml` +- `.gitea/workflows/cicd.yaml` - `Verify deployable runtime boundaries` - `Build and verify deployable runtime image purity` diff --git a/docs/DEPLOYABLE_RUNTIME_CONTRACT.md b/docs/DEPLOYABLE_RUNTIME_CONTRACT.md index 0c33cd2..b9caea0 100644 --- a/docs/DEPLOYABLE_RUNTIME_CONTRACT.md +++ b/docs/DEPLOYABLE_RUNTIME_CONTRACT.md @@ -30,6 +30,7 @@ Excluded: ### Runtime Artifact Definition - Container build source: `Dockerfile.backend`. +- Build shape: two-stage build with a dependency stage and a minimal runtime stage. - Runtime base image: `python:3.14-slim`. - Runtime process: `uvicorn backend.main:app --app-dir /app/src --host 0.0.0.0 --port 8000`. - Exposed runtime port: `8000`. @@ -79,6 +80,7 @@ The lockfile in `backend/uv.lock` is the dependency source of truth. ### Frontend Runtime Artifact Definition - Container build source: `Dockerfile.frontend` (target `production`). +- Build shape: two-stage build with a dependency/build stage and a minimal nginx runtime stage. - Runtime base image: `nginx:alpine`. - Runtime process: `nginx -g "daemon off;"`. - Exposed runtime port: `80`. @@ -151,14 +153,14 @@ Current enforcement implemented in CI: - Dockerfile target boundary checks: - Script: `scripts/check-dockerfile-boundaries.sh` - - Workflow: `.gitea/workflows/docker-build-main.yaml` + - Workflow: `.gitea/workflows/cicd.yaml` - Deployable runtime image purity checks: - Script: `scripts/verify-deployable-image-purity.sh` - - Workflow: `.gitea/workflows/docker-build-main.yaml` + - Workflow: `.gitea/workflows/cicd.yaml` - Checks include binary presence and profile-specific package metadata probes to detect CI/development tooling leakage. - Runtime black-box integration checks against deployed backend container: - - Workflow: `.gitea/workflows/cicd-tests.yaml` (`integration-tests` job) + - Workflow: `.gitea/workflows/cicd.yaml` (`integration-tests` job) - Inputs: deployable backend commit tag reference and immutable digest reference from main build dispatch. - Assertions: digest/tag consistency and live endpoint behavior for `/`, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d528284..82a4f75 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -36,7 +36,7 @@ Scope boundary: - Contract definition lives in `DEPLOYABLE_RUNTIME_CONTRACT.md`. - CI enforcement now includes Dockerfile boundary checks and deployable image - purity checks in `.gitea/workflows/docker-build-main.yaml`. + purity checks in `.gitea/workflows/cicd.yaml`. - Broader workflow redesign and deployment wiring remain out of scope for this repository's runtime contract document and belong to follow-up work under epic #66. @@ -60,8 +60,8 @@ Automated checks: and fails if CI/development binaries or package metadata artifacts are present. -These checks run in `.gitea/workflows/docker-build-main.yaml` before publishing -the complete CICD image. +These checks run in `.gitea/workflows/cicd.yaml` before publishing the +complete CICD image. ## Quick Start diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 1e05aeb..4f165a4 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,5 +1,10 @@ import { defineConfig, devices } from '@playwright/test'; +const runtimeBaseURL = process.env.PLAYWRIGHT_BASE_URL; +const useRuntimeServices = Boolean(runtimeBaseURL); +const junitOutputFile = process.env.PLAYWRIGHT_JUNIT_OUTPUT_FILE ?? 'playwright-results.xml'; +const outputDir = process.env.PLAYWRIGHT_OUTPUT_DIR ?? 'playwright-artifacts'; + export default defineConfig({ testDir: './tests/e2e', timeout: process.env.CI ? 60 * 1000 : 30 * 1000, // Longer timeout in CI @@ -10,11 +15,10 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI - ? [['list'], ['junit', { outputFile: 'playwright-results.xml' }]] - : 'html', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: junitOutputFile }]] : 'html', + outputDir, use: { - baseURL: 'http://localhost:5173', + baseURL: runtimeBaseURL ?? 'http://localhost:5173', trace: 'on-first-retry', headless: process.env.CI ? true : false, // CI-specific browser optimizations @@ -78,12 +82,16 @@ export default defineConfig({ }, }, ], - webServer: { - command: 'yarn dev --host 0.0.0.0 --port 5173 --strictPort', - url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, - timeout: process.env.CI ? 180 * 1000 : 120 * 1000, // Longer startup timeout in CI - stderr: 'pipe', - stdout: 'pipe', - }, + ...(useRuntimeServices + ? {} + : { + webServer: { + command: 'yarn dev --host 0.0.0.0 --port 5173 --strictPort', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: process.env.CI ? 180 * 1000 : 120 * 1000, // Longer startup timeout in CI + stderr: 'pipe', + stdout: 'pipe', + }, + }), });