Some checks failed
CICD / Build and Push CICD Image (push) Blocked by required conditions
CICD / Source Lanes Failure Postmortem (push) Blocked by required conditions
CICD / Runtime Black-Box Integration Tests (push) Blocked by required conditions
CICD / Build CICD Image Failure Postmortem (push) Blocked by required conditions
CICD / Backend Tests (push) Blocked by required conditions
CICD / Pre-commit Checks (push) Blocked by required conditions
CICD / Frontend Tests (push) Blocked by required conditions
CICD / Backend Doctests (push) Blocked by required conditions
CICD / CICD Tests Complete (push) Blocked by required conditions
CICD / Build Backend Base Image (push) Blocked by required conditions
CICD / Build Frontend Base Image (push) Blocked by required conditions
CICD / Build Integration Tester Image (push) Blocked by required conditions
CICD / Build E2E Tester Image (push) Blocked by required conditions
CICD / Build Backend Main Image (push) Blocked by required conditions
CICD / Build Frontend Main Image (push) Blocked by required conditions
CICD / Production Images Complete (push) Blocked by required conditions
CICD / Production Image Failures Postmortem (push) Blocked by required conditions
CICD / Integration Tests Failure Postmortem (push) Blocked by required conditions
CICD / End-to-End Tests (push) Blocked by required conditions
CICD / E2E Tests Failure Postmortem (push) Blocked by required conditions
CICD / Build and Publish CICD Base Image (pull_request) Successful in 40s
CICD / Build and Publish CICD Base Image (push) Failing after 11m54s
CICD / Build and Push CICD Image (pull_request) Successful in 24m38s
CICD / Build CICD Image Failure Postmortem (pull_request) Has been skipped
CICD / Backend Tests (pull_request) Successful in 36s
CICD / Backend Doctests (pull_request) Successful in 31s
CICD / Frontend Tests (pull_request) Successful in 10m26s
CICD / Pre-commit Checks (pull_request) Has been cancelled
CICD / CICD Tests Complete (pull_request) Has been cancelled
CICD / Build Backend Base Image (pull_request) Has been cancelled
CICD / Build Frontend Base Image (pull_request) Has been cancelled
CICD / Build Integration Tester Image (pull_request) Has been cancelled
CICD / Build E2E Tester Image (pull_request) Has been cancelled
CICD / Build Backend Main Image (pull_request) Has been cancelled
CICD / Build Frontend Main Image (pull_request) Has been cancelled
CICD / Production Images Complete (pull_request) Has been cancelled
CICD / Production Image Failures Postmortem (pull_request) Has been cancelled
CICD / Source Lanes Failure Postmortem (pull_request) Has been cancelled
CICD / Runtime Black-Box Integration Tests (pull_request) Has been cancelled
CICD / Integration Tests Failure Postmortem (pull_request) Has been cancelled
CICD / End-to-End Tests (pull_request) Has been cancelled
CICD / E2E Tests Failure Postmortem (pull_request) Has been cancelled
381 lines
17 KiB
YAML
381 lines
17 KiB
YAML
name: Renovate Dependency Updates
|
|
|
|
on:
|
|
schedule:
|
|
# Run Renovate every Monday at 8 AM UTC
|
|
- cron: '0 8 * * 1'
|
|
workflow_dispatch: # Allow manual triggering
|
|
inputs:
|
|
dry_run:
|
|
description: 'Run in dry-run mode (no changes made)'
|
|
required: false
|
|
default: 'false'
|
|
type: boolean
|
|
|
|
jobs:
|
|
renovate:
|
|
name: Renovate Dependencies
|
|
# Non-heavy workflow: allow any host exposing the generic ubuntu-act label.
|
|
runs-on: ubuntu-act
|
|
timeout-minutes: 90
|
|
|
|
steps:
|
|
- name: Prepare Renovate container image
|
|
env:
|
|
RENOVATE_IMAGE: ghcr.io/renovatebot/renovate:41
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
retry_cmd() {
|
|
attempts="${1:-3}"
|
|
backoff="${2:-10}"
|
|
shift 2
|
|
|
|
attempt=1
|
|
while [ "${attempt}" -le "${attempts}" ]; do
|
|
if "$@"; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "${attempt}" -lt "${attempts}" ]; then
|
|
sleep_seconds=$((backoff * attempt))
|
|
echo "Command failed (attempt ${attempt}/${attempts}): $*"
|
|
echo "Retrying in ${sleep_seconds}s"
|
|
sleep "${sleep_seconds}"
|
|
fi
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
echo "Command failed after ${attempts} attempts: $*"
|
|
return 1
|
|
}
|
|
|
|
echo "=== Preparing Renovate container ==="
|
|
echo "Using Renovate image: ${RENOVATE_IMAGE}"
|
|
free -h || true
|
|
|
|
retry_cmd 3 15 docker pull "${RENOVATE_IMAGE}"
|
|
docker run --rm "${RENOVATE_IMAGE}" --version
|
|
|
|
echo "RENOVATE_IMAGE=${RENOVATE_IMAGE}" >> "$GITHUB_ENV"
|
|
|
|
- name: Configure Renovate for Gitea
|
|
run: |
|
|
echo "=== Configuring Renovate for Gitea ==="
|
|
echo "✓ Renovate runtime configuration will be passed through environment and CLI flags"
|
|
|
|
- name: Run Renovate
|
|
env:
|
|
# Prefer dedicated Renovate token, then fall back to existing CI tokens.
|
|
RENOVATE_TOKEN_SECRET: ${{ secrets.RENOVATE_TOKEN }}
|
|
ACTIONS_TRIGGER_TOKEN: ${{ secrets.ACTIONS_TRIGGER_TOKEN }}
|
|
PACKAGE_ACCESS_TOKEN: ${{ secrets.PACKAGE_ACCESS_TOKEN }}
|
|
RENOVATE_DRY_RUN: ${{ inputs.dry_run }}
|
|
RENOVATE_PLATFORM: gitea
|
|
RENOVATE_ENDPOINT_INTERNAL: http://kankali.darkhelm.lan:3001/api/v1
|
|
RENOVATE_ENDPOINT_EXTERNAL: https://dogar.darkhelm.org/api/v1
|
|
RENOVATE_GIT_HOST: dogar.darkhelm.org
|
|
RENOVATE_GIT_USERNAME: ${{ github.actor }}
|
|
RENOVATE_ENDPOINT_IP: 10.18.75.2
|
|
RENOVATE_ALLOW_INSECURE_TLS: "true"
|
|
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate@darkhelm.org>
|
|
RENOVATE_ONBOARDING: "false"
|
|
RENOVATE_REQUIRE_CONFIG: required
|
|
RENOVATE_PR_CONCURRENT_LIMIT: "3"
|
|
RENOVATE_BRANCH_CONCURRENT_LIMIT: "5"
|
|
LOG_LEVEL: debug
|
|
run: |
|
|
echo "=== Running Renovate Bot ==="
|
|
TARGET_REPO="DarkHelm.org/plex-playlist"
|
|
TARGET_ORG="${TARGET_REPO%%/*}"
|
|
|
|
CURL_INSECURE_FLAG=""
|
|
if [ "${RENOVATE_ALLOW_INSECURE_TLS:-false}" = "true" ]; then
|
|
echo "⚠ Renovate insecure TLS mode enabled for self-signed certificate endpoint"
|
|
CURL_INSECURE_FLAG="--insecure"
|
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
fi
|
|
|
|
configure_endpoint() {
|
|
endpoint_input="${1%/}"
|
|
endpoint_ip_fallback="${2:-}"
|
|
|
|
if [[ "${endpoint_input}" == */api/v1 ]]; then
|
|
API_ENDPOINT="${endpoint_input}"
|
|
BASE_ENDPOINT="${endpoint_input%/api/v1}"
|
|
else
|
|
API_ENDPOINT="${endpoint_input}/api/v1"
|
|
BASE_ENDPOINT="${endpoint_input}"
|
|
fi
|
|
|
|
ENDPOINT_AUTHORITY="$(printf '%s' "${BASE_ENDPOINT}" | sed -E 's#^https?://([^/]+).*$#\1#')"
|
|
ENDPOINT_HOST="${ENDPOINT_AUTHORITY%%:*}"
|
|
ENDPOINT_IP="$(getent hosts "${ENDPOINT_HOST}" | awk '{print $1; exit}' || true)"
|
|
if [ -z "${ENDPOINT_IP}" ] && [ -n "${endpoint_ip_fallback}" ]; then
|
|
ENDPOINT_IP="${endpoint_ip_fallback}"
|
|
echo "Using configured Renovate endpoint IP fallback: ${ENDPOINT_HOST} -> ${ENDPOINT_IP}"
|
|
if ! grep -q "${ENDPOINT_HOST}" /etc/hosts; then
|
|
echo "${ENDPOINT_IP} ${ENDPOINT_HOST}" >> /etc/hosts
|
|
fi
|
|
fi
|
|
|
|
DOCKER_ADD_HOST_ARG=""
|
|
if [ -n "${ENDPOINT_IP}" ]; then
|
|
DOCKER_ADD_HOST_ARG="--add-host ${ENDPOINT_HOST}:${ENDPOINT_IP}"
|
|
echo "Renovate container host mapping: ${ENDPOINT_HOST} -> ${ENDPOINT_IP}"
|
|
else
|
|
echo "⚠ Unable to resolve ${ENDPOINT_HOST} on runner; proceeding without explicit --add-host"
|
|
fi
|
|
|
|
}
|
|
|
|
select_reachable_endpoint() {
|
|
for candidate in "${RENOVATE_ENDPOINT_INTERNAL}" "${RENOVATE_ENDPOINT_EXTERNAL}"; do
|
|
fallback_ip=""
|
|
if [ "${candidate}" = "${RENOVATE_ENDPOINT_INTERNAL}" ]; then
|
|
fallback_ip="${RENOVATE_ENDPOINT_IP:-}"
|
|
fi
|
|
|
|
configure_endpoint "${candidate}" "${fallback_ip}"
|
|
echo "Testing Renovate endpoint candidate: ${API_ENDPOINT}"
|
|
|
|
version_status=$(curl -sS -o /tmp/renovate-version-check.json -w "%{http_code}" \
|
|
${CURL_INSECURE_FLAG} \
|
|
"${API_ENDPOINT}/version" || true)
|
|
|
|
if [ "${version_status}" = "200" ]; then
|
|
SELECTED_ENDPOINT_INPUT="${candidate}"
|
|
VERSION_STATUS="${version_status}"
|
|
return 0
|
|
fi
|
|
|
|
echo "⚠ Endpoint candidate failed version preflight: ${API_ENDPOINT} (status=${version_status})"
|
|
if [ -s /tmp/renovate-version-check.json ]; then
|
|
cat /tmp/renovate-version-check.json || true
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
if ! select_reachable_endpoint; then
|
|
echo "❌ Unable to reach either configured Renovate endpoint"
|
|
echo "Tried internal: ${RENOVATE_ENDPOINT_INTERNAL}"
|
|
echo "Tried external: ${RENOVATE_ENDPOINT_EXTERNAL}"
|
|
exit 1
|
|
fi
|
|
|
|
# Force git clone base URL to the selected endpoint host/port.
|
|
# This avoids relying on platform-advertised clone URLs that may point to an unreachable external proxy.
|
|
export RENOVATE_GIT_URL="${BASE_ENDPOINT}/"
|
|
export RENOVATE_ENDPOINT="${BASE_ENDPOINT}"
|
|
echo "Renovate endpoint (primary): ${RENOVATE_ENDPOINT}"
|
|
echo "Renovate endpoint (fallback): ${RENOVATE_ENDPOINT_EXTERNAL}"
|
|
echo "Renovate git base URL override: ${RENOVATE_GIT_URL}"
|
|
echo "Preflight API endpoint: ${API_ENDPOINT}"
|
|
|
|
PLATFORM_VERSION=""
|
|
if [ "${VERSION_STATUS}" = "200" ]; then
|
|
PLATFORM_VERSION=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' /tmp/renovate-version-check.json | head -n 1)
|
|
if [ -n "${PLATFORM_VERSION}" ]; then
|
|
export RENOVATE_X_PLATFORM_VERSION="${PLATFORM_VERSION}"
|
|
echo "✓ Gitea version preflight passed: ${RENOVATE_X_PLATFORM_VERSION}"
|
|
else
|
|
echo "⚠ Gitea version preflight returned 200 but version could not be parsed"
|
|
cat /tmp/renovate-version-check.json || true
|
|
fi
|
|
else
|
|
echo "⚠ Gitea version preflight failed with status ${VERSION_STATUS}"
|
|
if [ -s /tmp/renovate-version-check.json ]; then
|
|
cat /tmp/renovate-version-check.json || true
|
|
fi
|
|
fi
|
|
|
|
select_token_with_repo_access() {
|
|
for candidate_name in RENOVATE_TOKEN_SECRET ACTIONS_TRIGGER_TOKEN PACKAGE_ACCESS_TOKEN; do
|
|
candidate_value="${!candidate_name:-}"
|
|
if [ -z "${candidate_value}" ]; then
|
|
continue
|
|
fi
|
|
|
|
USER_STATUS=$(curl -sS -o /tmp/renovate-auth-check-user.json -w "%{http_code}" \
|
|
${CURL_INSECURE_FLAG} \
|
|
-H "Authorization: token ${candidate_value}" \
|
|
"${API_ENDPOINT}/user" || true)
|
|
|
|
REPO_STATUS=$(curl -sS -o /tmp/renovate-auth-check-repo.json -w "%{http_code}" \
|
|
${CURL_INSECURE_FLAG} \
|
|
-H "Authorization: token ${candidate_value}" \
|
|
"${API_ENDPOINT}/repos/${TARGET_REPO}" || true)
|
|
|
|
ORG_STATUS=$(curl -sS -o /tmp/renovate-auth-check-org.json -w "%{http_code}" \
|
|
${CURL_INSECURE_FLAG} \
|
|
-H "Authorization: token ${candidate_value}" \
|
|
"${API_ENDPOINT}/orgs/${TARGET_ORG}" || true)
|
|
|
|
if [ "${REPO_STATUS}" = "200" ]; then
|
|
if [ "${USER_STATUS}" != "200" ] || [ "${ORG_STATUS}" != "200" ]; then
|
|
echo "⚠ Token candidate ${candidate_name} has repo access but limited user/org scopes (user=${USER_STATUS}, repo=${REPO_STATUS}, org=${ORG_STATUS})"
|
|
fi
|
|
echo "${candidate_name}:${candidate_value}"
|
|
return 0
|
|
fi
|
|
|
|
echo "⚠ Token candidate ${candidate_name} rejected (user=${USER_STATUS}, repo=${REPO_STATUS}, org=${ORG_STATUS})"
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
if ! SELECTED_TOKEN_RESULT="$(select_token_with_repo_access)"; then
|
|
echo "❌ No token available for Renovate authentication with repository access"
|
|
echo "Configure RENOVATE_TOKEN with at least repository read/write access; user/org read scopes are optional for this workflow."
|
|
echo "Token preflight checks attempted: ${API_ENDPOINT}/user, ${API_ENDPOINT}/repos/${TARGET_REPO}, and ${API_ENDPOINT}/orgs/${TARGET_ORG}"
|
|
if [ -s /tmp/renovate-auth-check-user.json ]; then
|
|
echo "Last user endpoint response body:"
|
|
cat /tmp/renovate-auth-check-user.json || true
|
|
fi
|
|
if [ -s /tmp/renovate-auth-check-repo.json ]; then
|
|
echo "Last repo endpoint response body:"
|
|
cat /tmp/renovate-auth-check-repo.json || true
|
|
fi
|
|
if [ -s /tmp/renovate-auth-check-org.json ]; then
|
|
echo "Last org endpoint response body:"
|
|
cat /tmp/renovate-auth-check-org.json || true
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
SELECTED_TOKEN_SOURCE="${SELECTED_TOKEN_RESULT%%:*}"
|
|
SELECTED_TOKEN="${SELECTED_TOKEN_RESULT#*:}"
|
|
export RENOVATE_TOKEN="${SELECTED_TOKEN}"
|
|
export RENOVATE_HOST_RULES="[{\"matchHost\":\"${ENDPOINT_AUTHORITY}\",\"token\":\"${SELECTED_TOKEN}\",\"authType\":\"Token-Only\"},{\"matchHost\":\"${RENOVATE_GIT_HOST}\",\"username\":\"${RENOVATE_GIT_USERNAME}\",\"password\":\"${SELECTED_TOKEN}\",\"authType\":\"Basic\"}]"
|
|
# Rewrite external clone URLs to an authenticated internal URL for git operations.
|
|
export RENOVATE_GIT_REWRITE_FROM="https://dogar.darkhelm.org/"
|
|
export RENOVATE_GIT_REWRITE_TO="http://${RENOVATE_GIT_USERNAME}:${SELECTED_TOKEN}@kankali.darkhelm.lan:3001/"
|
|
export GIT_CONFIG_COUNT=1
|
|
export GIT_CONFIG_KEY_0="url.${RENOVATE_GIT_REWRITE_TO}.insteadOf"
|
|
export GIT_CONFIG_VALUE_0="${RENOVATE_GIT_REWRITE_FROM}"
|
|
unset SELECTED_TOKEN RESULT_TOKEN
|
|
|
|
echo "✓ Renovate auth preflight passed with ${SELECTED_TOKEN_SOURCE}"
|
|
|
|
echo "=== Renovate container connectivity preflight ==="
|
|
# Prove whether the same image/container path Renovate uses can reach Gitea.
|
|
# If this fails, the problem is container network/TLS resolution, not Renovate config.
|
|
# shellcheck disable=SC2086
|
|
docker run --rm ${DOCKER_ADD_HOST_ARG} \
|
|
-e API_ENDPOINT="${API_ENDPOINT}" \
|
|
-e TARGET_REPO="${TARGET_REPO}" \
|
|
-e RENOVATE_TOKEN \
|
|
-e NODE_TLS_REJECT_UNAUTHORIZED \
|
|
"${RENOVATE_IMAGE}" \
|
|
node -e 'const endpoints = [["version", `${process.env.API_ENDPOINT}/version`, {}], ["repo", `${process.env.API_ENDPOINT}/repos/${process.env.TARGET_REPO}`, { headers: { Authorization: `token ${process.env.RENOVATE_TOKEN}` } }], ["user", `${process.env.API_ENDPOINT}/user`, { headers: { Authorization: `token ${process.env.RENOVATE_TOKEN}` }, optional: true }]]; let failed = false; (async () => { for (const [name, url, rawOptions] of endpoints) { const optional = Boolean(rawOptions.optional); const options = { ...rawOptions }; delete options.optional; try { const response = await fetch(url, options); console.log(`container_${name}_status=${response.status}`); if (!response.ok && !optional) { failed = true; console.log(`container_${name}_body_start`); console.log((await response.text()).slice(0, 1000)); console.log(`container_${name}_body_end`); } else if (!response.ok && optional) { console.log(`container_${name}_optional_scope_missing=true`); } } catch (error) { if (!optional) { failed = true; console.log(`container_${name}_error=${error instanceof Error ? error.message : String(error)}`); } else { console.log(`container_${name}_optional_probe_error=${error instanceof Error ? error.message : String(error)}`); } } } if (failed) { process.exit(1); } })();'
|
|
|
|
# Run Renovate with configuration
|
|
if [ "${RENOVATE_DRY_RUN}" = "true" ]; then
|
|
export RENOVATE_DRY_RUN="full"
|
|
echo "🔍 Running in DRY-RUN mode (no changes will be made)"
|
|
else
|
|
unset RENOVATE_DRY_RUN
|
|
fi
|
|
|
|
run_renovate_with_endpoint() {
|
|
endpoint="$1"
|
|
export RENOVATE_ENDPOINT="${endpoint}"
|
|
echo "Running Renovate with endpoint: ${RENOVATE_ENDPOINT}"
|
|
|
|
# shellcheck disable=SC2086
|
|
docker run --rm ${DOCKER_ADD_HOST_ARG} \
|
|
-e LOG_LEVEL \
|
|
-e NODE_TLS_REJECT_UNAUTHORIZED \
|
|
-e GIT_SSL_NO_VERIFY=1 \
|
|
-e GIT_CONFIG_COUNT \
|
|
-e GIT_CONFIG_KEY_0 \
|
|
-e GIT_CONFIG_VALUE_0 \
|
|
-e RENOVATE_TOKEN \
|
|
-e RENOVATE_GIT_URL \
|
|
-e RENOVATE_HOST_RULES \
|
|
-e RENOVATE_X_PLATFORM_VERSION \
|
|
-e RENOVATE_PLATFORM \
|
|
-e RENOVATE_ENDPOINT \
|
|
-e RENOVATE_DRY_RUN \
|
|
-e RENOVATE_GIT_AUTHOR \
|
|
-e RENOVATE_ONBOARDING \
|
|
-e RENOVATE_REQUIRE_CONFIG \
|
|
-e RENOVATE_PR_CONCURRENT_LIMIT \
|
|
-e RENOVATE_BRANCH_CONCURRENT_LIMIT \
|
|
"${RENOVATE_IMAGE}" \
|
|
--platform "${RENOVATE_PLATFORM}" \
|
|
--endpoint "${RENOVATE_ENDPOINT}" \
|
|
--git-url "${RENOVATE_GIT_URL}" \
|
|
--git-author "${RENOVATE_GIT_AUTHOR}" \
|
|
--onboarding="${RENOVATE_ONBOARDING}" \
|
|
--pr-concurrent-limit "${RENOVATE_PR_CONCURRENT_LIMIT}" \
|
|
--branch-concurrent-limit "${RENOVATE_BRANCH_CONCURRENT_LIMIT}" \
|
|
DarkHelm.org/plex-playlist
|
|
}
|
|
|
|
if run_renovate_with_endpoint "${BASE_ENDPOINT}" 2>&1 | tee /tmp/renovate.log; then
|
|
:
|
|
else
|
|
echo "⚠ Renovate run failed with primary endpoint. Retrying with API endpoint form..."
|
|
run_renovate_with_endpoint "${API_ENDPOINT}" 2>&1 | tee /tmp/renovate.log
|
|
fi
|
|
|
|
echo "✓ Renovate execution completed"
|
|
|
|
- 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
|
|
|
|
- name: Upload Renovate logs
|
|
if: always()
|
|
run: |
|
|
if [ -f "/tmp/renovate.log" ]; then
|
|
echo "=== Renovate Log Output ==="
|
|
echo "Last 50 lines of Renovate log:"
|
|
tail -50 /tmp/renovate.log
|
|
|
|
# Save log as artifact (if GitHub Actions artifact support exists)
|
|
mkdir -p /tmp/artifacts
|
|
cp /tmp/renovate.log /tmp/artifacts/renovate-$(date +%Y%m%d-%H%M%S).log
|
|
else
|
|
echo "No Renovate log file found"
|
|
fi
|
|
|
|
- name: Report Results
|
|
if: always()
|
|
run: |
|
|
echo "=== Renovate Execution Summary ==="
|
|
echo "Repository: DarkHelm.org/plex-playlist"
|
|
echo "Execution time: $(date)"
|
|
echo "Dry run mode: ${RENOVATE_DRY_RUN:-false}"
|
|
echo ""
|
|
echo "Check the Dependency Dashboard issue in your repository for detailed results:"
|
|
echo "https://dogar.darkhelm.org/DarkHelm.org/plex-playlist/issues"
|
|
echo ""
|
|
echo "Next scheduled run: Next Monday at 8 AM UTC"
|