diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3342dcf7..dcbfee8b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,14 +1,10 @@ name: Continuous Deployment on: - push: - branches: [ main ] - tags: [ 'v*' ] workflow_run: - workflows: ["Continuous Integration"] + workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"] types: - completed - branches: [ main ] workflow_dispatch: inputs: environment: @@ -19,6 +15,11 @@ on: options: - staging - production + image_tag: + description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy' + required: true + default: 'latest' + type: string force_deploy: description: 'Force deployment (skip checks)' required: false @@ -27,7 +28,7 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + IMAGE_NAME: ruvnet/wifi-densepose KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} jobs: @@ -35,7 +36,9 @@ jobs: pre-deployment: name: Pre-deployment Checks 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: deploy_env: ${{ steps.determine-env.outputs.environment }} image_tag: ${{ steps.determine-tag.outputs.tag }} @@ -43,6 +46,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} submodules: recursive - name: Determine deployment environment @@ -50,14 +54,12 @@ jobs: env: # Use environment variable to prevent shell injection 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 }} run: | if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then - echo "environment=staging" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + elif [[ "$PUBLISHED_REF" == v* ]]; then echo "environment=production" >> $GITHUB_OUTPUT else echo "environment=staging" >> $GITHUB_OUTPUT @@ -65,16 +67,23 @@ jobs: - name: Determine image 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: | - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT + elif [[ "$PUBLISHED_REF" == v* ]]; then + echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT else - echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT fi - name: Verify image exists 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-staging: @@ -129,7 +138,10 @@ jobs: name: Deploy to Production runs-on: ubuntu-latest 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: name: production url: https://wifi-densepose.com @@ -210,7 +222,7 @@ jobs: # kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0 - name: Upload deployment artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: production-deployment-${{ github.run_number }} path: | @@ -260,7 +272,7 @@ jobs: post-deployment: name: Post-deployment Monitoring 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') steps: - name: Monitor deployment health @@ -281,7 +293,7 @@ jobs: done - name: Update deployment status - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}'; @@ -300,7 +312,7 @@ jobs: notify: name: Notify Deployment Status runs-on: ubuntu-latest - needs: [deploy-staging, deploy-production, post-deployment] + needs: [pre-deployment, deploy-staging, deploy-production, post-deployment] if: always() steps: - name: Notify Slack on success @@ -332,7 +344,7 @@ jobs: - name: Create deployment issue on failure if: needs.deploy-production.result == 'failure' - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | github.rest.issues.create({ @@ -355,4 +367,4 @@ jobs: **Logs:** Check the workflow run for detailed error messages. `, labels: ['deployment', 'production', 'urgent'] - }) \ No newline at end of file + }) diff --git a/.github/workflows/pip-release.yml b/.github/workflows/pip-release.yml index 3896fd0f..782f9b34 100644 --- a/.github/workflows/pip-release.yml +++ b/.github/workflows/pip-release.yml @@ -34,9 +34,8 @@ # dedicated follow-up commit (drop `password:`, add the OIDC id-token # permission + `environment: pypi`) so there is no capability gap between. # -# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved -# before the first v2.0.0 publish. When v2 lands, add a parallel -# step that verifies the v2 hash against the Rust pipeline. +# Production publishing fails closed until the ADR-117 §11.3 v2 witness +# hash exists. TestPyPI remains usable to validate release artifacts. name: pip-release @@ -83,7 +82,7 @@ jobs: arch: x86_64 - os: ubuntu-latest arch: aarch64 - - os: macos-13 # x86_64 runner + - os: macos-15-intel # x86_64 runner arch: x86_64 - os: macos-14 # arm64 runner arch: arm64 @@ -152,6 +151,46 @@ jobs: path: sdist/*.tar.gz 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) # ──────────────────────────────────────────────────────────────── @@ -236,18 +275,29 @@ jobs: # ──────────────────────────────────────────────────────────────── publish-v2: - name: Publish v2 wheels - needs: [build-wheels, build-sdist] + name: Publish wifi-densepose + ruview + needs: [build-wheels, build-sdist, build-ruview] if: | always() && needs.build-wheels.result == 'success' && needs.build-sdist.result == 'success' && + needs.build-ruview.result == 'success' && ( github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || startsWith(github.ref, 'refs/tags/v2.') ) runs-on: ubuntu-latest 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/ uses: actions/download-artifact@v4 with: @@ -264,7 +314,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.TESTPYPI_API_TOKEN }} packages-dir: dist skip-existing: true - name: Publish to PyPI @@ -275,6 +325,7 @@ jobs: with: password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist + verbose: true publish-tombstone: name: Publish v1.99 tombstone @@ -299,7 +350,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.TESTPYPI_API_TOKEN }} packages-dir: dist skip-existing: true - name: Publish to PyPI diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e16eaa3..1468ebfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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). -- **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`). +- **`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` 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. ### Deprecated diff --git a/docs/integrations/pypi-release.md b/docs/integrations/pypi-release.md index 3c4e4cad..802f4bed 100644 --- a/docs/integrations/pypi-release.md +++ b/docs/integrations/pypi-release.md @@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow. ## Auth -The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`. -It's a project-token issued by the rUv PyPI account with upload -scope for both `wifi-densepose` and `ruview`. +Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a +project token issued by the rUv PyPI account with upload scope for both +`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 @@ -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/` 2. Verify: `pip install wifi-densepose==1.99.0; python -c "import wifi_densepose"` → ImportError with migration URL. -3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel - matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`. -4. (Optional, in lock-step) build + publish a matching `ruview` - release from `python/ruview-meta/` so the meta-package version - stays pinned to the same wifi-densepose version. +3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is + committed and non-empty. Production publishing fails closed without it. +4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2 + `wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are + 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 -- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256` - from the v2 Rust pipeline before any v2 publish. +- **Q3** (ADR-117 §11.3) — generate + `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; this is a deliberate choice to keep the secret refresh entirely in GCP. If the project migrates to OIDC later, remove `password:`