fix release publishing pipelines (#1417)

Make deployment consume the exact published sensing-server image, publish the two Python packages in lock-step, and enforce the production witness gate.
This commit is contained in:
rUv 2026-07-24 08:57:36 -07:00 committed by GitHub
parent 99700c7851
commit e1e10ad7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 42 deletions

View File

@ -1,14 +1,10 @@
name: Continuous Deployment name: Continuous Deployment
on: on:
push:
branches: [ main ]
tags: [ 'v*' ]
workflow_run: workflow_run:
workflows: ["Continuous Integration"] workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
types: types:
- completed - completed
branches: [ main ]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
environment: environment:
@ -19,6 +15,11 @@ on:
options: options:
- staging - staging
- production - production
image_tag:
description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy'
required: true
default: 'latest'
type: string
force_deploy: force_deploy:
description: 'Force deployment (skip checks)' description: 'Force deployment (skip checks)'
required: false required: false
@ -27,7 +28,7 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ruvnet/wifi-densepose
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
jobs: jobs:
@ -35,7 +36,9 @@ jobs:
pre-deployment: pre-deployment:
name: Pre-deployment Checks name: Pre-deployment Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' if: |
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
outputs: outputs:
deploy_env: ${{ steps.determine-env.outputs.environment }} deploy_env: ${{ steps.determine-env.outputs.environment }}
image_tag: ${{ steps.determine-tag.outputs.tag }} image_tag: ${{ steps.determine-tag.outputs.tag }}
@ -43,6 +46,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
submodules: recursive submodules: recursive
- name: Determine deployment environment - name: Determine deployment environment
@ -50,14 +54,12 @@ jobs:
env: env:
# Use environment variable to prevent shell injection # Use environment variable to prevent shell injection
GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_REF: ${{ github.ref }} PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }} GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
run: | run: |
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "environment=staging" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "environment=production" >> $GITHUB_OUTPUT echo "environment=production" >> $GITHUB_OUTPUT
else else
echo "environment=staging" >> $GITHUB_OUTPUT echo "environment=staging" >> $GITHUB_OUTPUT
@ -65,16 +67,23 @@ jobs:
- name: Determine image tag - name: Determine image tag
id: determine-tag id: determine-tag
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
INPUT_IMAGE_TAG: ${{ github.event.inputs.image_tag }}
run: | run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT
elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT
else else
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT
fi fi
- name: Verify image exists - name: Verify image exists
run: | run: |
docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }} docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}"
# Deploy to staging # Deploy to staging
deploy-staging: deploy-staging:
@ -129,7 +138,10 @@ jobs:
name: Deploy to Production name: Deploy to Production
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [pre-deployment, deploy-staging] needs: [pre-deployment, deploy-staging]
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success') if: |
always() &&
needs.pre-deployment.result == 'success' &&
needs.pre-deployment.outputs.deploy_env == 'production'
environment: environment:
name: production name: production
url: https://wifi-densepose.com url: https://wifi-densepose.com
@ -210,7 +222,7 @@ jobs:
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0 # kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
- name: Upload deployment artifacts - name: Upload deployment artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: production-deployment-${{ github.run_number }} name: production-deployment-${{ github.run_number }}
path: | path: |
@ -260,7 +272,7 @@ jobs:
post-deployment: post-deployment:
name: Post-deployment Monitoring name: Post-deployment Monitoring
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production] needs: [pre-deployment, deploy-staging, deploy-production]
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success') if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
steps: steps:
- name: Monitor deployment health - name: Monitor deployment health
@ -281,7 +293,7 @@ jobs:
done done
- name: Update deployment status - name: Update deployment status
uses: actions/github-script@v6 uses: actions/github-script@v7
with: with:
script: | script: |
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}'; const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
@ -300,7 +312,7 @@ jobs:
notify: notify:
name: Notify Deployment Status name: Notify Deployment Status
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production, post-deployment] needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
if: always() if: always()
steps: steps:
- name: Notify Slack on success - name: Notify Slack on success
@ -332,7 +344,7 @@ jobs:
- name: Create deployment issue on failure - name: Create deployment issue on failure
if: needs.deploy-production.result == 'failure' if: needs.deploy-production.result == 'failure'
uses: actions/github-script@v6 uses: actions/github-script@v7
with: with:
script: | script: |
github.rest.issues.create({ github.rest.issues.create({
@ -355,4 +367,4 @@ jobs:
**Logs:** Check the workflow run for detailed error messages. **Logs:** Check the workflow run for detailed error messages.
`, `,
labels: ['deployment', 'production', 'urgent'] labels: ['deployment', 'production', 'urgent']
}) })

View File

@ -34,9 +34,8 @@
# dedicated follow-up commit (drop `password:`, add the OIDC id-token # dedicated follow-up commit (drop `password:`, add the OIDC id-token
# permission + `environment: pypi`) so there is no capability gap between. # permission + `environment: pypi`) so there is no capability gap between.
# #
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved # Production publishing fails closed until the ADR-117 §11.3 v2 witness
# before the first v2.0.0 publish. When v2 lands, add a parallel # hash exists. TestPyPI remains usable to validate release artifacts.
# step that verifies the v2 hash against the Rust pipeline.
name: pip-release name: pip-release
@ -83,7 +82,7 @@ jobs:
arch: x86_64 arch: x86_64
- os: ubuntu-latest - os: ubuntu-latest
arch: aarch64 arch: aarch64
- os: macos-13 # x86_64 runner - os: macos-15-intel # x86_64 runner
arch: x86_64 arch: x86_64
- os: macos-14 # arm64 runner - os: macos-14 # arm64 runner
arch: arm64 arch: arm64
@ -152,6 +151,46 @@ jobs:
path: sdist/*.tar.gz path: sdist/*.tar.gz
if-no-files-found: error if-no-files-found: error
build-ruview:
name: Build ruview meta-package
if: |
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Verify lock-step package versions
shell: python
run: |
import pathlib
import tomllib
root = pathlib.Path("python")
core = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
meta = tomllib.loads((root / "ruview-meta" / "pyproject.toml").read_text(encoding="utf-8"))
core_version = core["project"]["version"]
meta_version = meta["project"]["version"]
expected_dependency = f"wifi-densepose=={core_version}"
if meta_version != core_version:
raise SystemExit(
f"package versions differ: wifi-densepose={core_version}, ruview={meta_version}"
)
if expected_dependency not in meta["project"]["dependencies"]:
raise SystemExit(f"ruview must depend on {expected_dependency}")
print(f"lock-step version: {core_version}")
- name: Build ruview wheel and sdist
run: |
python -m pip install --upgrade pip build
python -m build python/ruview-meta --outdir ruview-dist
- uses: actions/upload-artifact@v4
with:
name: ruview
path: ruview-dist/*
if-no-files-found: error
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel) # v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
@ -236,18 +275,29 @@ jobs:
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
publish-v2: publish-v2:
name: Publish v2 wheels name: Publish wifi-densepose + ruview
needs: [build-wheels, build-sdist] needs: [build-wheels, build-sdist, build-ruview]
if: | if: |
always() && always() &&
needs.build-wheels.result == 'success' && needs.build-wheels.result == 'success' &&
needs.build-sdist.result == 'success' && needs.build-sdist.result == 'success' &&
needs.build-ruview.result == 'success' &&
( (
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.') startsWith(github.ref, 'refs/tags/v2.')
) )
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- name: Enforce production witness gate
if: |
startsWith(github.ref, 'refs/tags/v2.') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
run: |
test -s archive/v1/data/proof/expected_features_v2.sha256 || {
echo "::error::ADR-117 §11.3 release gate is incomplete: archive/v1/data/proof/expected_features_v2.sha256 is missing or empty"
exit 1
}
- name: Gather all artifacts into dist/ - name: Gather all artifacts into dist/
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@ -264,7 +314,7 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:
repository-url: https://test.pypi.org/legacy/ repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }} password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist packages-dir: dist
skip-existing: true skip-existing: true
- name: Publish to PyPI - name: Publish to PyPI
@ -275,6 +325,7 @@ jobs:
with: with:
password: ${{ secrets.PYPI_API_TOKEN }} password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist packages-dir: dist
verbose: true
publish-tombstone: publish-tombstone:
name: Publish v1.99 tombstone name: Publish v1.99 tombstone
@ -299,7 +350,7 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:
repository-url: https://test.pypi.org/legacy/ repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }} password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist packages-dir: dist
skip-existing: true skip-existing: true
- name: Publish to PyPI - name: Publish to PyPI

View File

@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. Pip-release now authenticates via Trusted Publishing (see the entry below). **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload (ADR-184 P3) is still gated on the one-time manual Trusted Publisher registration on pypi.org that only the repo owner can perform. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client). - **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` publish job migrated to PyPI OIDC Trusted Publishing** (commit `cc153e8b5`; refs #785, completes ADR-117). The release workflow now authenticates to PyPI via short-lived OIDC tokens (`id-token: write`) instead of a long-lived `PYPI_API_TOKEN` secret. **Not yet active**: publishing will fail until the matching Trusted Publisher is registered manually on pypi.org (a one-time, per-project step that cannot be automated from CI) — ADR-184 P1 tracks this as the remaining gate (status recorded in `dfc4c1abd`). - **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies. - **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Deprecated ### Deprecated

View File

@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
## Auth ## Auth
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`. Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a
It's a project-token issued by the rUv PyPI account with upload project token issued by the rUv PyPI account with upload scope for both
scope for both `wifi-densepose` and `ruview`. `wifi-densepose` and `ruview`.
TestPyPI uses a separate `TESTPYPI_API_TOKEN` secret issued by
test.pypi.org. PyPI and TestPyPI accounts and tokens are independent.
## Refreshing the token ## Refreshing the token
@ -47,16 +50,19 @@ Per ADR-117 §7.3, the tombstone publishes first so it claims the
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/` tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import 2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
wifi_densepose"` → ImportError with migration URL. wifi_densepose"` → ImportError with migration URL.
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel 3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`. committed and non-empty. Production publishing fails closed without it.
4. (Optional, in lock-step) build + publish a matching `ruview` 4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2
release from `python/ruview-meta/` so the meta-package version `wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are
stays pinned to the same wifi-densepose version. published together. Their versions and dependency pin are checked in CI.
5. Verify both `https://pypi.org/project/wifi-densepose/2.0.0/` and
`https://pypi.org/project/ruview/2.0.0/`.
## Off-loop manual gates ## Off-loop manual gates
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256` - **Q3** (ADR-117 §11.3) — generate
from the v2 Rust pipeline before any v2 publish. `archive/v1/data/proof/expected_features_v2.sha256` from the v2 Rust
pipeline before a production v2 publish. The workflow enforces this gate.
- **OIDC Trusted Publisher** — not used. The workflow is token-based; - **OIDC Trusted Publisher** — not used. The workflow is token-based;
this is a deliberate choice to keep the secret refresh entirely in this is a deliberate choice to keep the secret refresh entirely in
GCP. If the project migrates to OIDC later, remove `password:` GCP. If the project migrates to OIDC later, remove `password:`