Merge pull request #958 from joaomqc/patch-1 #1593
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker Image | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| bootstrap_ghcr: | |
| description: "Use GHCR_TOKEN once to create the renamed floppy package" | |
| required: false | |
| default: false | |
| type: boolean | |
| push: | |
| branches: | |
| - "release" | |
| - "latest" | |
| tags: | |
| - "v*" | |
| pull_request: | |
| branches: | |
| - "latest" | |
| # A push to latest must not be overtaken by an older build that started later | |
| # because runners were queued. Keep one publish per ref in flight and let the | |
| # newest pending run replace intermediate commits without cancelling the run | |
| # currently publishing. | |
| concurrency: | |
| group: docker-image-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: dannyvfilms/floppy | |
| LEGACY_IMAGE_NAME: dannyvfilms/yamtrack | |
| jobs: | |
| test: | |
| name: Smoke built image | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| env: | |
| SMOKE_APP: floppy-smoke-app-${{ github.run_id }}-${{ github.run_attempt }} | |
| SMOKE_IMAGE: floppy:ci-smoke | |
| SMOKE_NETWORK: floppy-smoke-${{ github.run_id }}-${{ github.run_attempt }} | |
| SMOKE_REDIS: floppy-smoke-redis-${{ github.run_id }}-${{ github.run_attempt }} | |
| SMOKE_SHA: ${{ github.sha }} | |
| SMOKE_VOLUME: floppy-smoke-db-${{ github.run_id }}-${{ github.run_attempt }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Setup Docker buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build test image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| load: true | |
| tags: ${{ env.SMOKE_IMAGE }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| provenance: false | |
| platforms: linux/amd64 | |
| build-args: | | |
| VERSION=ci-smoke | |
| COMMIT_SHA=${{ env.SMOKE_SHA }} | |
| - name: Run and restart test image | |
| run: | | |
| set -euo pipefail | |
| wait_for_redis() { | |
| for _ in $(seq 1 30); do | |
| if timeout 10 docker exec "$SMOKE_REDIS" redis-cli ping | grep -qx PONG; then | |
| return 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "Redis did not become ready" >&2 | |
| return 1 | |
| } | |
| wait_for_health() { | |
| for _ in $(seq 1 60); do | |
| state=$(docker inspect --format '{{.State.Status}}' "$SMOKE_APP") | |
| health=$(docker inspect --format '{{.State.Health.Status}}' "$SMOKE_APP") | |
| if [ "$health" = healthy ]; then | |
| return 0 | |
| fi | |
| if [ "$state" = exited ] || [ "$state" = dead ]; then | |
| echo "Floppy exited before becoming healthy" >&2 | |
| return 1 | |
| fi | |
| sleep 5 | |
| done | |
| echo "Floppy did not become healthy" >&2 | |
| return 1 | |
| } | |
| assert_runtime() { | |
| curl -fsS --max-time 10 http://127.0.0.1:8000/health/ >/dev/null | |
| login_html=$(curl -fsS --max-time 10 http://127.0.0.1:8000/accounts/login/) | |
| grep -Fq 'alt="Floppy"' <<<"$login_html" | |
| curl -fsS --max-time 10 http://127.0.0.1:8000/static/css/main.css >/dev/null | |
| curl -fsS --max-time 10 http://127.0.0.1:8000/api/v1/info/ | | |
| python3 -c 'import json, sys; assert json.load(sys.stdin)["version"] == "ci-smoke"' | |
| test "$(docker exec "$SMOKE_APP" printenv VERSION)" = ci-smoke | |
| test "$(docker exec "$SMOKE_APP" printenv COMMIT_SHA)" = "$SMOKE_SHA" | |
| timeout 30 docker exec "$SMOKE_APP" test -s /floppy/db/db.sqlite3 | |
| timeout 120 docker exec "$SMOKE_APP" python manage.py migrate --check --noinput | |
| timeout 30 docker exec "$SMOKE_APP" python manage.py shell -c \ | |
| 'from django.conf import settings; import os, redis; assert settings.VERSION == os.environ["VERSION"] == "ci-smoke"; assert settings.COMMIT_SHA == os.environ["COMMIT_SHA"]; assert redis.Redis.from_url(settings.REDIS_URL).ping()' | |
| timeout 30 docker exec "$SMOKE_APP" python -c \ | |
| 'import importlib.metadata; import floppy_mcp; assert importlib.metadata.version("floppy-mcp")' | |
| timeout 10 docker exec "$SMOKE_APP" sh -c \ | |
| 'timeout 5 floppy-mcp </dev/null; status=$?; [ "$status" -eq 0 ] || [ "$status" -eq 124 ]' | |
| # The startup-scan observability path (heartbeat, status sidecar, | |
| # timeout marking) must actually be present in the image, not just | |
| # in source. | |
| timeout 10 docker exec "$SMOKE_APP" python -c \ | |
| 'from config.sqlite_integrity import mark_startup_status_timeout, print_startup_heartbeat, read_startup_status, write_startup_status' | |
| # The build-info file must exist and match this build, and a | |
| # shadowing runtime environment override (an orchestrator's | |
| # persisted .env, for example) must not be able to defeat it -- | |
| # this is the failure a real deployment hit. | |
| build_info=$(docker exec "$SMOKE_APP" cat /etc/floppy-build-info) | |
| grep -Fxq "VERSION=ci-smoke" <<<"$build_info" | |
| grep -Fxq "COMMIT_SHA=$SMOKE_SHA" <<<"$build_info" | |
| restored=$(docker exec -e COMMIT_SHA=stale-override -e VERSION=stale-override "$SMOKE_APP" sh -c \ | |
| '. /etc/floppy-build-info; export VERSION COMMIT_SHA; echo "$VERSION $COMMIT_SHA"') | |
| test "$restored" = "ci-smoke $SMOKE_SHA" | |
| # HOST_CONTAINERNAME is unset in this smoke run, so the identity | |
| # line must fall back to "floppy" rather than erroring. Captured | |
| # into a variable first: piping a long-lived command straight | |
| # into "grep -q" trips pipefail on the writer's SIGPIPE once grep | |
| # finds its match and closes the pipe early. | |
| smoke_logs=$(docker logs "$SMOKE_APP" 2>&1) | |
| grep -Fq "[entrypoint] Floppy runtime: version=ci-smoke commit=$SMOKE_SHA" <<<"$smoke_logs" | |
| grep -Fq "container_name=floppy" <<<"$smoke_logs" | |
| } | |
| start_app() { | |
| docker run --detach \ | |
| --name "$SMOKE_APP" \ | |
| --network "$SMOKE_NETWORK" \ | |
| --publish 127.0.0.1:8000:8000 \ | |
| --env TZ=UTC \ | |
| --env SECRET=ci-smoke-only-not-a-production-secret \ | |
| --env REDIS_URL="redis://$SMOKE_REDIS:6379" \ | |
| --env ADMIN_ENABLED=False \ | |
| --env DEMO_ACCOUNT_ENABLED=False \ | |
| --env DEBUG=False \ | |
| --env FLOPPY_RESOURCE_TIER=minimal \ | |
| --volume "$SMOKE_VOLUME:/floppy/db" \ | |
| "$SMOKE_IMAGE" | |
| } | |
| image_env=$(docker image inspect "$SMOKE_IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}') | |
| grep -Fxq VERSION=ci-smoke <<<"$image_env" | |
| grep -Fxq "COMMIT_SHA=$SMOKE_SHA" <<<"$image_env" | |
| docker network create "$SMOKE_NETWORK" | |
| docker volume create "$SMOKE_VOLUME" | |
| docker run --detach --name "$SMOKE_REDIS" --network "$SMOKE_NETWORK" redis:8-alpine | |
| wait_for_redis | |
| start_app | |
| wait_for_health | |
| assert_runtime | |
| docker logs "$SMOKE_APP" >"$RUNNER_TEMP/floppy-smoke-first.log" 2>&1 | |
| docker rm --force "$SMOKE_APP" | |
| start_app | |
| wait_for_health | |
| assert_runtime | |
| - name: Print test container logs | |
| if: failure() | |
| run: | | |
| if [ -f "$RUNNER_TEMP/floppy-smoke-first.log" ]; then | |
| echo "First Floppy boot logs:" | |
| tail -n 500 "$RUNNER_TEMP/floppy-smoke-first.log" | |
| fi | |
| for container in "$SMOKE_APP" "$SMOKE_REDIS"; do | |
| if docker inspect "$container" >/dev/null 2>&1; then | |
| echo "$container logs:" | |
| docker logs --tail 500 "$container" 2>&1 || true | |
| fi | |
| done | |
| - name: Clean up test containers | |
| if: always() | |
| run: | | |
| docker rm --force "$SMOKE_APP" "$SMOKE_REDIS" 2>/dev/null || true | |
| docker network rm "$SMOKE_NETWORK" 2>/dev/null || true | |
| docker volume rm "$SMOKE_VOLUME" 2>/dev/null || true | |
| build: | |
| needs: test | |
| runs-on: ubuntu-latest | |
| if: github.actor != 'dependabot[bot]' | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 # This ensures all tags are fetched for git describe | |
| - name: Get version from git | |
| id: get-version | |
| run: | | |
| VERSION=$(git describe --tags 2>/dev/null || echo "dev") | |
| echo "VERSION=$VERSION" >> $GITHUB_OUTPUT | |
| echo "Using version: $VERSION" | |
| - name: Setup Docker buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v4 | |
| - name: Extract Docker metadata tags | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| # Dual-publish during the rename grace period: the legacy path keeps | |
| # receiving identical tags so pre-rename deployments still get | |
| # updates. Drop the second line once traffic to it has died off. | |
| images: | | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| ${{ env.REGISTRY }}/${{ env.LEGACY_IMAGE_NAME }} | |
| tags: | | |
| type=ref,event=pr | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=semver,pattern={{major}} | |
| type=raw,value=latest,enable=${{ github.ref == 'refs/heads/latest' }} | |
| type=raw,value=release,enable=${{ github.ref == 'refs/heads/release' || startsWith(github.ref, 'refs/tags/v') }} | |
| # Pull requests build both architectures to prove the image still | |
| # cross-compiles, but they must never write to the registry. A pr-N tag | |
| # has no consumer and lands in the same public package users pull from, | |
| # where nothing distinguishes it from a release. Publishing happens on | |
| # push to latest/release and on v* tags only. | |
| - name: Log into registry ${{ env.REGISTRY }} | |
| if: github.event_name != 'pull_request' | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| # The one-time bootstrap is needed because floppy was created by a | |
| # PAT-based push, while the existing yamtrack package is already | |
| # associated with this repository. Normal builds stay on GITHUB_TOKEN. | |
| username: ${{ inputs.bootstrap_ghcr == true && 'dannyvfilms' || github.actor }} | |
| password: ${{ inputs.bootstrap_ghcr == true && secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }} | |
| - name: Build and push Docker image | |
| id: build-and-push | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| push: ${{ github.event_name != 'pull_request' }} | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| provenance: false | |
| platforms: linux/amd64,linux/arm64 | |
| build-args: | | |
| VERSION=${{ steps.get-version.outputs.VERSION }} | |
| COMMIT_SHA=${{ github.sha }} |