pip-audit fixes to unblock release #29
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
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: Build and test only; do not push to Docker Hub | |
| type: boolean | |
| default: true | |
| concurrency: | |
| group: release-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| IMAGE: touchthesun/openhardwaremanager | |
| FRONTEND_IMAGE: touchthesun/openhardwaremanager-frontend | |
| PYTHON_VERSION: "3.12" | |
| # Published images must be pullable on Apple Silicon and linux/amd64 CI/cloud. | |
| PLATFORMS: linux/amd64,linux/arm64 | |
| jobs: | |
| validate: | |
| name: Validate release version | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.meta.outputs.version }} | |
| major_minor: ${{ steps.meta.outputs.major_minor }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Validate tag vs pyproject.toml | |
| id: meta | |
| run: | | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| python3 scripts/validate_release_version.py --tag "${GITHUB_REF_NAME}" | |
| else | |
| python3 scripts/validate_release_version.py | |
| fi | |
| test: | |
| name: Pre-release tests | |
| runs-on: ubuntu-latest | |
| needs: validate | |
| env: | |
| ENVIRONMENT: test | |
| LOG_LEVEL: INFO | |
| STORAGE_PROVIDER: local | |
| LLM_ENABLED: false | |
| RUN_AZURE_CHAIN_TESTS: "0" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install dependencies | |
| run: | | |
| uv sync --extra dev | |
| uv pip install pytest-cov pip-audit | |
| - name: Run unit lane | |
| run: | | |
| uv run pytest tests \ | |
| -m "unit and not llm and not quarantine" \ | |
| --maxfail=1 | |
| - name: Run contract lane | |
| run: | | |
| uv run pytest tests \ | |
| -m "contract and not llm and not quarantine" \ | |
| --maxfail=1 | |
| - name: Run contract stability suite | |
| run: | | |
| uv run pytest \ | |
| tests/api/test_scaffold_cleanup_endpoint.py \ | |
| tests/api/test_package_download_route.py \ | |
| tests/cli/test_scaffold_cleanup_cli.py \ | |
| tests/federation/test_federation_routes.py \ | |
| -m "not llm and not quarantine" \ | |
| --maxfail=1 | |
| - name: Dependency vulnerability scan | |
| run: uv run pip-audit | |
| docker-smoke: | |
| name: Docker build and smoke test | |
| runs-on: ubuntu-latest | |
| needs: validate | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| push: false | |
| load: true | |
| tags: ${{ env.IMAGE }}:${{ needs.validate.outputs.version }} | |
| build-args: | | |
| APP_VERSION=${{ needs.validate.outputs.version }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| - name: Smoke test container | |
| run: | | |
| EXPECTED="${{ needs.validate.outputs.version }}" | |
| docker run -d --name release-smoke \ | |
| -p 8001:8001 \ | |
| -e ENVIRONMENT=test \ | |
| -e STORAGE_PROVIDER=local \ | |
| -e LLM_ENABLED=false \ | |
| "${{ env.IMAGE }}:${EXPECTED}" | |
| for i in $(seq 1 30); do | |
| if curl -sf http://localhost:8001/health >/dev/null; then | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| ACTUAL=$(curl -sf http://localhost:8001/health | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])") | |
| echo "health version=$ACTUAL (expected $EXPECTED)" | |
| test "$ACTUAL" = "$EXPECTED" | |
| docker rm -f release-smoke | |
| publish: | |
| name: Publish multi-arch to Docker Hub | |
| runs-on: ubuntu-latest | |
| needs: [validate, test, docker-smoke] | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.dry_run == false) | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| with: | |
| driver-opts: image=moby/buildkit:latest | |
| - name: Log in to Docker Hub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Build and push multi-arch manifest | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| platforms: ${{ env.PLATFORMS }} | |
| push: true | |
| tags: | | |
| ${{ env.IMAGE }}:${{ needs.validate.outputs.version }} | |
| ${{ env.IMAGE }}:${{ needs.validate.outputs.major_minor }} | |
| ${{ env.IMAGE }}:latest | |
| build-args: | | |
| APP_VERSION=${{ needs.validate.outputs.version }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| - name: Publish summary | |
| run: | | |
| echo "Published multi-arch (${{ env.PLATFORMS }}):" | |
| echo " ${{ env.IMAGE }}:${{ needs.validate.outputs.version }}" | |
| echo " ${{ env.IMAGE }}:${{ needs.validate.outputs.major_minor }}" | |
| echo " ${{ env.IMAGE }}:latest" | |
| # Deploys to the live Azure Container App (openhardwaremanager / project_data_rg). | |
| # Gated by the "production" GitHub Environment: the federated OIDC credential's | |
| # subject is `repo:...:environment:production`, so this job can only obtain an | |
| # Azure token while running under that environment, and any required-reviewer | |
| # protection rule configured on it (Settings → Environments → production) blocks | |
| # the job until approved. Uses --set-env-vars (additive) on an existing app, so | |
| # it applies the image tag + the non-secret per-env config (ENVIRONMENT, | |
| # CORS_ORIGINS, and the storage target -- provider/account/container) from | |
| # config/environments/production.toml. Secrets (storage key, LLM encryption) | |
| # stay secretRefs configured directly on the container and are untouched. | |
| deploy-azure: | |
| name: Deploy to Azure Container Apps | |
| runs-on: ubuntu-latest | |
| needs: [validate, publish] | |
| if: github.event_name == 'push' | |
| environment: production | |
| permissions: | |
| id-token: write | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install dependencies | |
| # deploy_azure.py imports src.config (pydantic-settings, python-dotenv, | |
| # …), so the deploy step needs the project's runtime deps installed. | |
| run: uv sync | |
| - name: Azure login (OIDC) | |
| uses: azure/login@v2 | |
| with: | |
| client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| - name: Deploy | |
| # --environment is explicit: the deploy target must never be inferred | |
| # (locally it would be read from a developer's .env — a prod footgun). | |
| run: | | |
| uv run python deploy/scripts/deploy_azure.py \ | |
| --image ${{ env.IMAGE }}:${{ needs.validate.outputs.version }} \ | |
| --subscription-id ${{ secrets.AZURE_SUBSCRIPTION_ID }} \ | |
| --environment production | |
| - name: Verify deployment | |
| env: | |
| API_HOST: openhardwaremanager.blackdune-e38fce01.westus3.azurecontainerapps.io | |
| EXPECTED_VERSION: ${{ needs.validate.outputs.version }} | |
| # Lifespan startup (storage + MATCHING_EAGER_INIT up to 120s) can exceed | |
| # the old 20×5s window; use liveness (no storage fingerprint) and tolerate | |
| # empty/non-JSON gateway responses during revision rollout. | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| host = os.environ["API_HOST"] | |
| expected = os.environ["EXPECTED_VERSION"] | |
| url = f"https://{host}/health/liveness" | |
| actual = None | |
| for attempt in range(1, 37): | |
| try: | |
| with urllib.request.urlopen(url, timeout=10) as resp: | |
| body = json.load(resp) | |
| actual = body.get("version") | |
| if actual == expected: | |
| print(f"deployed version={actual} (expected {expected})", flush=True) | |
| sys.exit(0) | |
| print( | |
| f"attempt {attempt}/36: version={actual!r} (waiting for {expected!r})", | |
| flush=True, | |
| ) | |
| except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc: | |
| print(f"attempt {attempt}/36: not ready ({type(exc).__name__})", flush=True) | |
| time.sleep(10) | |
| print(f"deployed version={actual!r} (expected {expected!r})") | |
| sys.exit(1) | |
| PY | |
| - name: Verify storage fingerprint (drift + emptiness gate) | |
| # Assert the live app is actually pointed at the container the repo | |
| # declares (config/environments/production.toml) AND that it is non-empty. | |
| # Emptiness is a deploy-gate failure (never promote a release onto an | |
| # empty/mis-pointed prod container) even though it is only a startup warning. | |
| env: | |
| API_HOST: openhardwaremanager.blackdune-e38fce01.westus3.azurecontainerapps.io | |
| run: | | |
| python3 - <<'PY' | |
| import json, sys, time, tomllib, urllib.error, urllib.request, os | |
| host = os.environ["API_HOST"] | |
| with open("config/environments/production.toml", "rb") as f: | |
| expected = tomllib.load(f).get("azure_storage_container", "") | |
| fp = {} | |
| for attempt in range(1, 13): | |
| try: | |
| with urllib.request.urlopen(f"https://{host}/health", timeout=30) as r: | |
| fp = json.load(r).get("storage", {}) | |
| if fp.get("container") is not None: | |
| break | |
| except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: | |
| print(f"attempt {attempt}/12: /health not ready ({type(exc).__name__})") | |
| time.sleep(5) | |
| container, okh, okw = fp.get("container"), fp.get("okh_count"), fp.get("okw_count") | |
| print(f"live container={container!r} expected={expected!r} okh={okh} okw={okw}") | |
| errors = [] | |
| if expected and container != expected: | |
| errors.append(f"container drift: live {container!r} != config {expected!r}") | |
| if not okh: | |
| errors.append("okh_count is 0 or missing") | |
| if not okw: | |
| errors.append("okw_count is 0 or missing") | |
| if errors: | |
| sys.exit("DEPLOY GATE FAILED: " + "; ".join(errors)) | |
| print("deploy gate passed") | |
| PY | |
| # Frontend (nginx SPA + /v1 reverse-proxy) — built multi-arch so the image runs | |
| # on linux/amd64 hosts (a bare `docker build` on Apple Silicon is arm64-only and | |
| # fails to activate on Azure Container Apps). | |
| publish-frontend: | |
| name: Publish frontend multi-arch to Docker Hub | |
| runs-on: ubuntu-latest | |
| needs: [validate, test, docker-smoke] | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.dry_run == false) | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| with: | |
| driver-opts: image=moby/buildkit:latest | |
| - name: Log in to Docker Hub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Build and push multi-arch frontend manifest | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: frontend | |
| platforms: ${{ env.PLATFORMS }} | |
| push: true | |
| tags: | | |
| ${{ env.FRONTEND_IMAGE }}:${{ needs.validate.outputs.version }} | |
| ${{ env.FRONTEND_IMAGE }}:${{ needs.validate.outputs.major_minor }} | |
| ${{ env.FRONTEND_IMAGE }}:latest | |
| cache-from: type=gha,scope=frontend | |
| cache-to: type=gha,mode=max,scope=frontend | |
| - name: Publish summary | |
| run: | | |
| echo "Published multi-arch frontend (${{ env.PLATFORMS }}):" | |
| echo " ${{ env.FRONTEND_IMAGE }}:${{ needs.validate.outputs.version }}" | |
| echo " ${{ env.FRONTEND_IMAGE }}:${{ needs.validate.outputs.major_minor }}" | |
| echo " ${{ env.FRONTEND_IMAGE }}:latest" | |
| # Deploys the frontend container app (openhardwaremanager-frontend). Mirrors | |
| # deploy-azure: OIDC into the "production" GitHub Environment, then updates the | |
| # image + applies API_UPSTREAM_URL / PORT from config/environments/production.toml | |
| # [frontend] via deploy_azure_frontend.py. --environment is explicit (never | |
| # inferred). The container app itself is provisioned once out-of-band. | |
| deploy-frontend-azure: | |
| name: Deploy frontend to Azure Container Apps | |
| runs-on: ubuntu-latest | |
| needs: [validate, publish, publish-frontend] | |
| if: github.event_name == 'push' | |
| environment: production | |
| permissions: | |
| id-token: write | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install dependencies | |
| # deploy_azure_frontend.py imports src.config (frontend_deploy_env_vars). | |
| run: uv sync | |
| - name: Azure login (OIDC) | |
| uses: azure/login@v2 | |
| with: | |
| client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} | |
| - name: Deploy frontend | |
| run: | | |
| uv run python deploy/scripts/deploy_azure_frontend.py \ | |
| --image ${{ env.FRONTEND_IMAGE }}:${{ needs.validate.outputs.version }} \ | |
| --subscription-id ${{ secrets.AZURE_SUBSCRIPTION_ID }} \ | |
| --environment production | |
| - name: Verify frontend deployment | |
| run: | | |
| host=openhardwaremanager-frontend.blackdune-e38fce01.westus3.azurecontainerapps.io | |
| code=000 | |
| for i in $(seq 1 30); do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://$host/healthz" || true) | |
| [ "$code" = "200" ] && break | |
| sleep 5 | |
| done | |
| echo "frontend /healthz -> $code" | |
| test "$code" = "200" | |
| # The /v1 reverse-proxy reaches the backend API (same-origin). | |
| curl -sf --max-time 20 "https://$host/v1/openapi.json" >/dev/null | |
| echo "frontend proxy -> backend /v1 OK" | |
| # Git tags alone do not appear under GitHub → Releases. This job creates the | |
| # Release page entry (notes, source archives) after Docker publish succeeds. | |
| github-release: | |
| name: Create GitHub Release | |
| runs-on: ubuntu-latest | |
| needs: [validate, publish] | |
| if: github.event_name == 'push' | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Build release notes | |
| run: | | |
| VERSION="${{ needs.validate.outputs.version }}" | |
| python3 scripts/extract_changelog_section.py "$VERSION" -o /tmp/release-notes.md | |
| { | |
| echo "" | |
| echo "---" | |
| echo "" | |
| echo "### Docker" | |
| echo "" | |
| echo '```bash' | |
| echo "docker pull ${{ env.IMAGE }}:${VERSION}" | |
| echo '```' | |
| echo "" | |
| echo "Multi-arch: \`${{ env.PLATFORMS }}\`" | |
| } >> /tmp/release-notes.md | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ github.ref_name }} | |
| name: Open Hardware Manager ${{ needs.validate.outputs.version }} | |
| body_path: /tmp/release-notes.md | |
| generate_release_notes: false | |
| make_latest: true |