feat(import): add resumable deferred file recovery #315
Workflow file for this run
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
| --- | |
| # Pullbox Docker Validation Pipeline | |
| # PR/manual Docker checks prove that image changes build, scan, and smoke-test | |
| # without publishing anything to GHCR or Docker Hub. | |
| # | |
| # Trusted pull requests run the full production Docker Hardened Images build on | |
| # the dedicated Docker runner. Untrusted pull requests fall back to a reduced | |
| # public sanity check that does not require secrets or self-hosted runners. | |
| # GitHub Actions owns Docker validation for PR checks. | |
| name: Docker Validate | |
| on: | |
| pull_request: | |
| branches: | |
| - develop | |
| - main | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - ready_for_review | |
| - labeled | |
| - unlabeled | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: docker-validate-${{ github.ref || github.run_id }} | |
| cancel-in-progress: true | |
| env: | |
| PYTHON_DEFAULT: "3.14" | |
| jobs: | |
| release_sync_check: | |
| name: Release Sync Check | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| is_sync: ${{ steps.release-sync.outputs.is_sync }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - name: Detect safe post-release sync PR | |
| id: release-sync | |
| env: | |
| RELEASE_SYNC_EVENT_NAME: ${{ github.event_name }} | |
| RELEASE_SYNC_BASE_REF: ${{ github.base_ref || '' }} | |
| RELEASE_SYNC_HEAD_REF: ${{ github.head_ref || '' }} | |
| RELEASE_SYNC_REPOSITORY: ${{ github.repository }} | |
| RELEASE_SYNC_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || '' }} | |
| RELEASE_SYNC_ACTOR: ${{ github.actor }} | |
| run: | | |
| git fetch --no-tags origin main:refs/remotes/origin/main | |
| if ! git cat-file -e origin/main:.github/scripts/validate-release-sync-pr.py 2>/dev/null; then | |
| echo "is_sync=false" >> "$GITHUB_OUTPUT" | |
| echo "reason=trusted release sync validator is not available on origin/main yet" >> "$GITHUB_OUTPUT" | |
| echo "release_sync=false" | |
| echo "reason=trusted release sync validator is not available on origin/main yet" | |
| exit 0 | |
| fi | |
| git show origin/main:.github/scripts/validate-release-sync-pr.py > /tmp/pullbox-release-sync-validator.py | |
| python /tmp/pullbox-release-sync-validator.py | |
| full_ci_check: | |
| name: Full Docker Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| run_full: ${{ steps.gate.outputs.run_full }} | |
| trusted_full: ${{ steps.gate.outputs.trusted_full }} | |
| untrusted_full: ${{ steps.gate.outputs.untrusted_full }} | |
| label_required: ${{ steps.gate.outputs.label_required }} | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Resolve Docker validation gate | |
| id: gate | |
| run: | | |
| echo "run_full=${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.actor == 'dependabot[bot]' || startsWith(github.event.pull_request.head.ref, 'dependabot/') || contains(github.event.pull_request.labels.*.name, 'ci:full'))) }}" >> "$GITHUB_OUTPUT" | |
| echo "trusted_full=${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && !startsWith(github.event.pull_request.head.ref, 'dependabot/') && contains(github.event.pull_request.labels.*.name, 'ci:full')) }}" >> "$GITHUB_OUTPUT" | |
| echo "untrusted_full=${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.actor == 'dependabot[bot]' || startsWith(github.event.pull_request.head.ref, 'dependabot/')) }}" >> "$GITHUB_OUTPUT" | |
| echo "label_required=${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && !startsWith(github.event.pull_request.head.ref, 'dependabot/') && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}" >> "$GITHUB_OUTPUT" | |
| untrusted-sanity: | |
| name: Docker Sanity (untrusted PR) | |
| needs: [release_sync_check, full_ci_check] | |
| if: needs.release_sync_check.outputs.is_sync != 'true' && needs.full_ci_check.outputs.untrusted_full == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Explain reduced validation | |
| run: | | |
| echo "Repository secrets are unavailable for fork or Dependabot pull requests." | |
| echo "Running a reduced Docker sanity check instead of the trusted Docker Hardened Images build." | |
| - name: Build local development image | |
| run: docker build -f docker/Dockerfile.dev -t pullbox:pr-sanity . | |
| - name: Inspect built development image | |
| run: docker image inspect pullbox:pr-sanity >/dev/null | |
| - name: Cleanup | |
| if: always() | |
| run: docker image rm -f pullbox:pr-sanity || true | |
| trusted-production-validate: | |
| name: Production Docker Validate (trusted) | |
| needs: [release_sync_check, full_ci_check] | |
| if: needs.release_sync_check.outputs.is_sync != 'true' && needs.full_ci_check.outputs.trusted_full == 'true' | |
| runs-on: [self-hosted, Linux, X64, docker] | |
| timeout-minutes: 35 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Runner preflight | |
| run: .github/scripts/preflight-runner.sh docker | |
| - name: Validate DHI authentication | |
| env: | |
| DHI_USERNAME: ${{ secrets.DHI_USERNAME }} | |
| DHI_TOKEN: ${{ secrets.DHI_TOKEN }} | |
| run: | | |
| if [ -z "${DHI_USERNAME}" ] || [ -z "${DHI_TOKEN}" ]; then | |
| echo "::error::DHI_USERNAME and DHI_TOKEN repository secrets are required to build Docker Hardened Images in CI." | |
| exit 1 | |
| fi | |
| - name: Log in to DHI | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: dhi.io | |
| username: ${{ secrets.DHI_USERNAME }} | |
| password: ${{ secrets.DHI_TOKEN }} | |
| - name: Build production Docker image | |
| run: | | |
| BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| GIT_SHA="${GITHUB_SHA}" | |
| docker build \ | |
| -f docker/Dockerfile \ | |
| -t pullbox:validate \ | |
| --build-arg BUILD_DATE="${BUILD_DATE}" \ | |
| --build-arg GIT_SHA="${GIT_SHA}" \ | |
| --build-arg GIT_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" \ | |
| --build-arg VERSION="validate-${GIT_SHA::7}" \ | |
| . | |
| - name: Inspect built production image | |
| run: docker image inspect pullbox:validate >/dev/null | |
| - name: Verify container security runtime | |
| run: | | |
| docker run --rm -i --entrypoint python pullbox:validate - \ | |
| < scripts/verify_container_security_runtime.py | |
| - name: Run Grype scan | |
| uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 | |
| with: | |
| image: pullbox:validate | |
| grype-version: v0.110.0 | |
| fail-build: true | |
| severity-cutoff: high | |
| output-format: table | |
| config: .grype.yaml | |
| - name: Verify packaged static assets | |
| run: | | |
| docker run --rm --entrypoint python pullbox:validate -c ' | |
| import pullbox.app | |
| assets = [ | |
| pullbox.app.STATIC_DIR / "css" / "tailwind.css", | |
| pullbox.app.STATIC_DIR / "js" / "htmx.min.js", | |
| ] | |
| missing = [str(asset) for asset in assets if not asset.is_file()] | |
| if missing: | |
| raise SystemExit("Missing packaged static assets: " + ", ".join(missing)) | |
| print("Packaged static assets verified") | |
| ' | |
| - name: Start container | |
| run: | | |
| docker run -d \ | |
| --name pullbox-validate \ | |
| -p 127.0.0.1::8585 \ | |
| -e PULLBOX_SECRET_KEY=docker-validate-secret-key-for-ci \ | |
| -e PULLBOX_LOG_LEVEL=WARNING \ | |
| pullbox:validate | |
| validate_port="$(docker port pullbox-validate 8585/tcp | awk -F: 'NR == 1 { print $NF }')" | |
| if [ -z "${validate_port}" ]; then | |
| echo "::error::Could not determine Pullbox validation port." | |
| docker port pullbox-validate || true | |
| exit 1 | |
| fi | |
| echo "PULLBOX_VALIDATE_URL=http://127.0.0.1:${validate_port}" >> "$GITHUB_ENV" | |
| echo "Container listening on http://127.0.0.1:${validate_port}" | |
| - name: Wait for healthy | |
| run: | | |
| for i in $(seq 1 30); do | |
| if curl -sf "${PULLBOX_VALIDATE_URL}/ping" > /dev/null 2>&1; then | |
| echo "Container healthy after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "Container failed to become healthy" | |
| docker logs pullbox-validate | |
| exit 1 | |
| - name: Write Docker validation summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## Docker Validation" | |
| echo "" | |
| echo "- Production image build: pullbox:validate" | |
| echo "- Grype scan: high severity gate" | |
| echo "- Smoke test: /ping health check" | |
| echo "- Publish: disabled for validation workflow" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Collect container logs on failure | |
| if: failure() || cancelled() | |
| run: | | |
| mkdir -p test-results/docker-validate | |
| docker logs pullbox-validate > test-results/docker-validate/container.log 2>&1 || true | |
| - name: Upload Docker validation failure artifacts | |
| if: failure() || cancelled() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: docker-validate-failure-artifacts | |
| path: test-results/docker-validate/ | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Cleanup | |
| if: always() | |
| run: | | |
| docker rm -f pullbox-validate || true | |
| docker image rm -f pullbox:validate || true | |
| docker-validate-required: | |
| name: Docker Validate Required | |
| runs-on: ubuntu-latest | |
| needs: | |
| - release_sync_check | |
| - full_ci_check | |
| - untrusted-sanity | |
| - trusted-production-validate | |
| if: always() | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Verify Docker validation jobs | |
| env: | |
| NEEDS_CONTEXT: ${{ toJson(needs) }} | |
| RELEASE_SYNC_PR: ${{ needs.release_sync_check.outputs.is_sync }} | |
| RUN_FULL: ${{ needs.full_ci_check.outputs.run_full }} | |
| TRUSTED_FULL: ${{ needs.full_ci_check.outputs.trusted_full }} | |
| UNTRUSTED_FULL: ${{ needs.full_ci_check.outputs.untrusted_full }} | |
| LABEL_REQUIRED: ${{ needs.full_ci_check.outputs.label_required }} | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| import sys | |
| needs = json.loads(os.environ["NEEDS_CONTEXT"]) | |
| release_sync_pr = os.environ["RELEASE_SYNC_PR"].lower() == "true" | |
| run_full = os.environ["RUN_FULL"].lower() == "true" | |
| trusted_full = os.environ["TRUSTED_FULL"].lower() == "true" | |
| untrusted_full = os.environ["UNTRUSTED_FULL"].lower() == "true" | |
| label_required = os.environ["LABEL_REQUIRED"].lower() == "true" | |
| if release_sync_pr: | |
| release_sync = needs.get("release_sync_check", {}).get("result") | |
| if release_sync != "success": | |
| print(f"Release sync validation failed: {release_sync}", file=sys.stderr) | |
| sys.exit(1) | |
| unexpected = { | |
| name: info.get("result") | |
| for name, info in needs.items() | |
| if name != "release_sync_check" and info.get("result") not in {"skipped", "success"} | |
| } | |
| if unexpected: | |
| print(f"Release sync fast path saw unexpected Docker results: {unexpected}", file=sys.stderr) | |
| sys.exit(1) | |
| print("Release sync fast path validated; Docker validation intentionally skipped.") | |
| sys.exit(0) | |
| if not run_full: | |
| if label_required: | |
| print("Docker validation is waiting for ci:full before running the required Docker gate.", file=sys.stderr) | |
| else: | |
| print("Docker validation was intentionally not requested for this PR state.", file=sys.stderr) | |
| sys.exit(1) | |
| if trusted_full: | |
| result = needs.get("trusted-production-validate", {}).get("result") | |
| if result != "success": | |
| print(f"Trusted Docker validation did not pass: {result}", file=sys.stderr) | |
| sys.exit(1) | |
| print("Trusted Docker validation passed.") | |
| sys.exit(0) | |
| if untrusted_full: | |
| result = needs.get("untrusted-sanity", {}).get("result") | |
| if result != "success": | |
| print(f"Untrusted Docker sanity validation did not pass: {result}", file=sys.stderr) | |
| sys.exit(1) | |
| print("Untrusted Docker sanity validation passed.") | |
| sys.exit(0) | |
| print("Docker validation gate resolved to no runnable validation path.", file=sys.stderr) | |
| sys.exit(1) | |
| PY |