348 lines
12 KiB
YAML
348 lines
12 KiB
YAML
name: CI
|
|
|
|
on:
|
|
pull_request:
|
|
types: [opened, synchronize, reopened, ready_for_review]
|
|
branches: ["*"]
|
|
pull_request_target:
|
|
types: [closed]
|
|
branches: ["*"]
|
|
push:
|
|
branches: [main, dev, beta, next]
|
|
merge_group:
|
|
workflow_dispatch:
|
|
inputs:
|
|
operation:
|
|
description: Operation to run
|
|
required: true
|
|
default: validate
|
|
type: choice
|
|
options:
|
|
- validate
|
|
- publish
|
|
- cleanup
|
|
pr_number:
|
|
description: PR number for publish or cleanup
|
|
required: false
|
|
type: number
|
|
ref:
|
|
description: Git ref to validate or publish
|
|
required: false
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
|
|
env:
|
|
FORCE_COLOR: 3
|
|
SKIP_ENV_VALIDATION: true
|
|
TURBO_TELEMETRY_DISABLED: 1
|
|
REGISTRY: ghcr.io
|
|
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
|
|
|
|
jobs:
|
|
fast-gate:
|
|
name: Fast gate
|
|
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
env:
|
|
TURBO_API: ${{ secrets.TURBO_API }}
|
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
|
|
TURBO_SCM_HEAD: ${{ github.sha }}
|
|
TURBO_PLATFORM: linux/amd64
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
if: '!env.ACT'
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
|
|
|
- name: Validate manual inputs
|
|
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
|
|
env:
|
|
PR_NUMBER: ${{ inputs.pr_number }}
|
|
BUILD_REF: ${{ inputs.ref }}
|
|
run: |
|
|
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
|
|
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
|
|
|
|
- name: Setup
|
|
uses: ./tooling/github/setup
|
|
|
|
- name: Copy environment defaults
|
|
run: cp .env.example .env
|
|
|
|
- name: Check formatting in affected workspaces
|
|
run: pnpm turbo run format --affected --continue -- --check
|
|
|
|
- name: Lint, typecheck, and build affected workspaces
|
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
|
|
|
- name: Validate workspace configuration
|
|
run: pnpm lint:ws
|
|
|
|
- name: Run unit tests
|
|
run: pnpm test
|
|
|
|
- name: Report coverage
|
|
if: always() && !env.ACT
|
|
uses: davelosert/vitest-coverage-report-action@v2
|
|
|
|
container:
|
|
name: Container, E2E, and memory
|
|
needs: fast-gate
|
|
if: |
|
|
github.event_name == 'merge_group' ||
|
|
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
|
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
if: '!env.ACT'
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
|
|
|
- name: Setup
|
|
uses: ./tooling/github/setup
|
|
|
|
- name: Set up Docker Buildx
|
|
if: '!env.ACT'
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
- name: Build container once
|
|
if: '!env.ACT'
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
network: host
|
|
platforms: linux/amd64
|
|
load: true
|
|
push: false
|
|
tags: |
|
|
homarr-ci
|
|
homarr-e2e
|
|
labels: |
|
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
|
org.opencontainers.image.revision=${{ github.sha }}
|
|
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
|
cache-from: type=gha,scope=homarr-linux-amd64
|
|
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
|
secrets: |
|
|
TURBO_API=${{ secrets.TURBO_API }}
|
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
|
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
|
|
- name: Build container once with Act
|
|
if: env.ACT
|
|
env:
|
|
TURBO_API: ${{ secrets.TURBO_API }}
|
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
|
run: |
|
|
secret_args=()
|
|
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
|
|
if [ -n "${!secret:-}" ]; then
|
|
secret_args+=(--secret "id=${secret},env=${secret}")
|
|
fi
|
|
done
|
|
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
|
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
|
|
|
- name: Cache Playwright browsers
|
|
if: '!env.ACT'
|
|
id: playwright-cache
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: ~/.cache/ms-playwright
|
|
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
|
|
- name: Install Playwright Chromium
|
|
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
|
run: pnpm exec playwright install --with-deps chromium
|
|
|
|
- name: Install Playwright system deps
|
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
run: pnpm exec playwright install-deps chromium
|
|
|
|
- name: Run E2E tests
|
|
run: pnpm test:e2e
|
|
|
|
- name: Upload E2E artifacts
|
|
if: failure() && !env.ACT
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: playwright-report
|
|
path: e2e/test-results/
|
|
retention-days: 7
|
|
|
|
- name: Run memory regression test
|
|
env:
|
|
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
|
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
|
run: |
|
|
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
|
|
-e NO_EXTERNAL_CONNECTION=true \
|
|
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
|
|
homarr-ci
|
|
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
|
|
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
|
|
URL="http://127.0.0.1:${PORT}"
|
|
|
|
healthy=false
|
|
for i in $(seq 1 30); do
|
|
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
|
|
echo "Container healthy after $((i * 2))s"
|
|
healthy=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
if [ "$healthy" != "true" ]; then
|
|
echo "ERROR: Container never became healthy"
|
|
docker logs "$CONTAINER_NAME" --tail 100
|
|
exit 1
|
|
fi
|
|
|
|
warmup_ok=0
|
|
for _ in $(seq 1 5); do
|
|
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
|
|
sleep 2
|
|
done
|
|
if [ "$warmup_ok" -eq 0 ]; then
|
|
echo "ERROR: All warmup requests failed"
|
|
docker logs "$CONTAINER_NAME" --tail 50
|
|
exit 1
|
|
fi
|
|
|
|
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
|
|
echo "ERROR: Container died before memory measurement"
|
|
docker logs "$CONTAINER_NAME" --tail 100
|
|
exit 1
|
|
}
|
|
|
|
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
|
|
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
|
|
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
|
|
/MiB/ { gsub(/MiB/, ""); print $1; next }
|
|
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
|
|
{ print $1 / 1048576 }
|
|
')
|
|
|
|
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
|
|
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
|
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
|
|
docker logs "$CONTAINER_NAME" --tail 100
|
|
exit 1
|
|
}
|
|
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
|
|
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
|
|
docker logs "$CONTAINER_NAME" --tail 100
|
|
exit 1
|
|
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
|
|
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
|
|
docker logs "$CONTAINER_NAME" --tail 100
|
|
exit 1
|
|
fi
|
|
|
|
- name: Clean up memory test container
|
|
if: always()
|
|
env:
|
|
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
|
|
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
|
|
|
- name: Determine preview tag
|
|
id: preview
|
|
if: |
|
|
!env.ACT &&
|
|
(
|
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
|
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
|
)
|
|
env:
|
|
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
|
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to GHCR
|
|
if: steps.preview.outputs.tag != ''
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Push preview image
|
|
if: steps.preview.outputs.tag != ''
|
|
env:
|
|
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
|
|
run: |
|
|
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
|
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
|
|
|
- name: Show Act publication result
|
|
if: env.ACT
|
|
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
|
|
|
cleanup:
|
|
name: Clean up preview image
|
|
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
packages: write
|
|
env:
|
|
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
|
steps:
|
|
- name: Validate cleanup input
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
PR_NUMBER: ${{ inputs.pr_number }}
|
|
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
|
|
|
|
- name: Delete GHCR package version
|
|
if: '!env.ACT'
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
const packageName = "homarr-test";
|
|
const org = context.repo.owner;
|
|
const tag = process.env.IMAGE_TAG;
|
|
const versions = await github.paginate(
|
|
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
|
|
{ package_type: "container", package_name: packageName, org, per_page: 100 },
|
|
);
|
|
const target = versions.find((version) =>
|
|
version.metadata?.container?.tags?.includes(tag),
|
|
);
|
|
if (!target) {
|
|
core.info(`No package version found with tag "${tag}"`);
|
|
return;
|
|
}
|
|
await github.rest.packages.deletePackageVersionForOrg({
|
|
package_type: "container",
|
|
package_name: packageName,
|
|
org,
|
|
package_version_id: target.id,
|
|
});
|
|
core.info(`Deleted package version ${target.id} (${tag})`);
|
|
|
|
- name: Show Act cleanup result
|
|
if: env.ACT
|
|
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
|