diff --git a/.env.example b/.env.example index e4057b5..8442c96 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ DSPY_MODEL_NAME=nvidia/nemotron-3-nano-30b-a3b:free # ============================================================================ # These map to GitHub Actions secrets/variables used by: # - .github/workflows/publish-platform-images.yml +# - .github/workflows/publish-foundry.yml # - .github/workflows/railway-preview-smoke.yml # - .github/workflows/railway-production-smoke.yml # diff --git a/.github/workflows/publish-foundry.yml b/.github/workflows/publish-foundry.yml new file mode 100644 index 0000000..4499e97 --- /dev/null +++ b/.github/workflows/publish-foundry.yml @@ -0,0 +1,212 @@ +name: publish-foundry + +on: + pull_request: + paths: + - ".github/workflows/publish-foundry.yml" + - "Dockerfile" + - "openapi.foundry.json" + - "scripts/deploy/foundry_openapi.py" + - "src/api/**" + - "src/common/**" + - "src/serving/**" + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_version: + description: "Optional release version (e.g. 0.1.2). When set, publishes versioned tag to Foundry." + required: false + type: string + +permissions: + contents: read + +jobs: + publish: + name: Build and push image + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Generate + validate Foundry OpenAPI artifact + run: | + uv run python scripts/deploy/foundry_openapi.py \ + --generate \ + --spec-path openapi.foundry.json \ + --server-url http://localhost:5000 + uv run python scripts/deploy/foundry_openapi.py \ + --spec-path openapi.foundry.json \ + --server-url http://localhost:5000 + git diff --exit-code -- openapi.foundry.json + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Resolve tags + id: meta + env: + FOUNDRY_REGISTRY_HOST: ${{ secrets.FOUNDRY_REGISTRY_HOST }} + FOUNDRY_DOCKER_IMAGE_NAME: ${{ secrets.FOUNDRY_DOCKER_IMAGE_NAME }} + run: | + set -euo pipefail + + short_sha="${GITHUB_SHA::12}" + publish="false" + release_version="" + + if [[ "${GITHUB_REF_TYPE:-}" == "tag" && "${GITHUB_REF_NAME:-}" == v* ]]; then + publish="true" + release_version="${GITHUB_REF_NAME#v}" + fi + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${{ inputs.release_version }}" ]]; then + publish="true" + release_version="${{ inputs.release_version }}" + fi + + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + image_ref="local/foundry" + tag="${image_ref}:pr-${short_sha}" + elif [[ "${publish}" == "true" ]]; then + : "${FOUNDRY_REGISTRY_HOST:?missing FOUNDRY_REGISTRY_HOST secret}" + : "${FOUNDRY_DOCKER_IMAGE_NAME:?missing FOUNDRY_DOCKER_IMAGE_NAME secret}" + image_ref="${FOUNDRY_REGISTRY_HOST}/${FOUNDRY_DOCKER_IMAGE_NAME}" + tag="${image_ref}:${release_version}" + else + # Manual build without release_version still validates the image, but does not push. + image_ref="local/foundry" + tag="${image_ref}:manual-${short_sha}" + fi + + { + echo "image_ref=${image_ref}" + echo "tag=${tag}" + echo "publish=${publish}" + echo "release_version=${release_version}" + } >> "${GITHUB_OUTPUT}" + + - name: Serialize Foundry OpenAPI label + id: openapi + run: | + value="$(uv run python -c 'import json; print(json.dumps(json.load(open("openapi.foundry.json", encoding="utf-8")), separators=(",", ":")))')" + { + echo "value<> "${GITHUB_OUTPUT}" + + - name: Login to Foundry registry + if: steps.meta.outputs.publish == 'true' + env: + FOUNDRY_ARTIFACT_REPOSITORY_RID: ${{ secrets.FOUNDRY_ARTIFACT_REPOSITORY_RID }} + FOUNDRY_TOKEN: ${{ secrets.FOUNDRY_TOKEN }} + FOUNDRY_REGISTRY_HOST: ${{ secrets.FOUNDRY_REGISTRY_HOST }} + run: | + set -euo pipefail + : "${FOUNDRY_ARTIFACT_REPOSITORY_RID:?missing FOUNDRY_ARTIFACT_REPOSITORY_RID secret}" + : "${FOUNDRY_TOKEN:?missing FOUNDRY_TOKEN secret}" + : "${FOUNDRY_REGISTRY_HOST:?missing FOUNDRY_REGISTRY_HOST secret}" + echo "${FOUNDRY_TOKEN}" | docker login -u "${FOUNDRY_ARTIFACT_REPOSITORY_RID}" --password-stdin "${FOUNDRY_REGISTRY_HOST}" + + - name: Build (and maybe push) + env: + OPENAPI_JSON: ${{ steps.openapi.outputs.value }} + TAG: ${{ steps.meta.outputs.tag }} + PUBLISH: ${{ steps.meta.outputs.publish }} + run: | + set -euo pipefail + + if [[ "${PUBLISH}" == "true" ]]; then + docker buildx build \ + --platform linux/amd64 \ + --build-arg SERVER_OPENAPI="${OPENAPI_JSON}" \ + --tag "${TAG}" \ + --provenance=false \ + --sbom=false \ + --push \ + . + else + docker buildx build \ + --platform linux/amd64 \ + --build-arg SERVER_OPENAPI="${OPENAPI_JSON}" \ + --tag "${TAG}" \ + --provenance=false \ + --sbom=false \ + --load \ + . + fi + + - name: Validate pushed image metadata (Foundry) + if: steps.meta.outputs.publish == 'true' + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + docker pull "${TAG}" + uv run python scripts/deploy/foundry_openapi.py \ + --spec-path openapi.foundry.json \ + --image-ref "${TAG}" \ + --server-url http://localhost:5000 + + - name: Validate image metadata (PR/manual) + if: steps.meta.outputs.publish != 'true' + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + uv run python scripts/deploy/foundry_openapi.py \ + --spec-path openapi.foundry.json \ + --image-ref "${TAG}" \ + --server-url http://localhost:5000 + + - name: Foundry runtime smoke test (PORT=5000) + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + + container_id="$(docker run -d --rm -e PORT=5000 -e DSPY_PROVIDER=local -p 15000:5000 "${TAG}")" + trap 'docker rm -f "${container_id}" >/dev/null 2>&1 || true' RETURN + + for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:15000/health" >/dev/null 2>&1; then + echo "Health check passed" + exit 0 + fi + sleep 1 + done + + echo "Health check failed" + docker logs "${container_id}" || true + exit 1 + + - name: Workflow summary + if: always() + run: | + set -euo pipefail + { + echo "## Foundry Docker Publish" + echo "" + echo "- Event: \`${GITHUB_EVENT_NAME}\`" + echo "- Image: \`${{ steps.meta.outputs.tag }}\`" + if [[ "${{ steps.meta.outputs.publish }}" == "true" ]]; then + echo "- Publish: yes" + echo "- Release version: \`${{ steps.meta.outputs.release_version }}\`" + else + echo "- Publish: no (build + validate only)" + fi + } >> "${GITHUB_STEP_SUMMARY}" + diff --git a/.github/workflows/publish-platform-images.yml b/.github/workflows/publish-platform-images.yml index bd465c7..80f7e7b 100644 --- a/.github/workflows/publish-platform-images.yml +++ b/.github/workflows/publish-platform-images.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: inputs: release_version: - description: "Optional release version (e.g. 0.1.2). When set, publishes versioned tags to Foundry + GHCR." + description: "Optional release version (e.g. 0.1.2). When set, publishes versioned tags to GHCR." required: false type: string @@ -41,45 +41,30 @@ jobs: id: tags env: GHCR_IMAGE: ghcr.io/${{ github.repository }} - FOUNDRY_REGISTRY_HOST: ${{ secrets.FOUNDRY_REGISTRY_HOST }} - FOUNDRY_DOCKER_IMAGE_NAME: ${{ secrets.FOUNDRY_DOCKER_IMAGE_NAME }} run: | set -euo pipefail image_tag="$(date -u +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}" short_sha="${GITHUB_SHA::12}" - publish_foundry="false" release_version="" if [[ "${GITHUB_REF_TYPE:-}" == "tag" && "${GITHUB_REF_NAME:-}" == v* ]]; then - publish_foundry="true" release_version="${GITHUB_REF_NAME#v}" fi if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${{ inputs.release_version }}" ]]; then - publish_foundry="true" release_version="${{ inputs.release_version }}" fi - foundry_image="" - if [[ "${publish_foundry}" == "true" ]]; then - : "${FOUNDRY_REGISTRY_HOST:?missing FOUNDRY_REGISTRY_HOST secret}" - : "${FOUNDRY_DOCKER_IMAGE_NAME:?missing FOUNDRY_DOCKER_IMAGE_NAME secret}" - foundry_image="${FOUNDRY_REGISTRY_HOST}/${FOUNDRY_DOCKER_IMAGE_NAME}" - fi - { echo "ghcr_image=${GHCR_IMAGE}" - echo "foundry_image=${foundry_image}" echo "image_tag=${image_tag}" - echo "publish_foundry=${publish_foundry}" echo "release_version=${release_version}" echo "tags<