Skip to content

Merge pull request #277 from helpfulengineering/file-browsing #26

Merge pull request #277 from helpfulengineering/file-browsing

Merge pull request #277 from helpfulengineering/file-browsing #26

Workflow file for this run

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