# Build the `omnigent` release distributions (core wheel with the web # UI bundled in, plus the `omnigent-client` and `omnigent-ui-sdk` SDK # wheels it depends on), run the readiness gates, and publish all three to # (Test)PyPI via OIDC Trusted Publishing. The three version-lock together, # so every release publishes all three at the same version. Self-contained: # publishes straight from this repo on `ubuntu-latest` for clean public # PyPI/npm access (never a mirror/proxy). # # Release flow: # 1. Push a version tag (vX.Y.Z / vX.Y.ZrcN) -> build + gate + publish to # TestPyPI automatically. # 2. Validate the TestPyPI release (install + smoke). # 3. Manually dispatch ON THE SAME TAG with destination=pypi -> publish # the identical version to PyPI, behind the protected `pypi` env. # # One-time setup (per index, pypi.org AND test.pypi.org): Trusted Publishers # for all three project names pointing at omnigent-ai/omnigent + # release-omnigent.yml + the test-pypi/pypi environment (unclaimed names # reserved via a pending publisher); GitHub envs test-pypi (unprotected) # and pypi (required reviewer). Actions are SHA-pinned per repo convention. name: Release omnigent (PyPI) on: # PyPI publishing moved to the central secure-release repo; the tag-push # trigger is REMOVED so a tag no longer double-publishes. Kept as a manual # fallback only, to be deleted once the secure path has done a prod release. # Manual run: build + gates always run; destination picks the index, with # `pypi` binding the protected environment. workflow_dispatch: inputs: destination: description: "Index to publish to." type: choice default: test-pypi options: - test-pypi - pypi # CI-test the workflow on change: build + gates run on the PR; publish # steps are condition-gated off PR events. pull_request: paths: - ".github/workflows/release-omnigent.yml" permissions: contents: read id-token: write # OIDC Trusted Publishing — consumed by the publish steps # Serialize releases on the same ref so two triggers can't race the same tag. concurrency: group: release-omnigent-${{ github.ref }} cancel-in-progress: false jobs: build-and-publish: # Gated to this repository; inert in forks and mirrors. if: github.repository == 'omnigent-ai/omnigent' runs-on: ubuntu-latest # Bound a hung run under GitHub's 6-hour default (real work takes minutes). timeout-minutes: 30 # Environment binds the Trusted Publisher config: test-pypi unprotected, # pypi gated by a required-reviewer rule for real releases. environment: name: ${{ (github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi') && 'pypi' || 'test-pypi' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up uv (clean public resolution, no proxy cache) uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: false - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20" # 1. Build the web UI FIRST into the package tree, clean. Ordering is # load-bearing: the wheel packages on-disk files, so the bundle must # exist before `uv build`. `rm -rf` backstops Vite's emptyOutDir # against stale bundles; `npm ci` installs the exact locked deps. # `--legacy-peer-deps` matches how web's lockfile is generated and # validated everywhere else (lint, e2e-ui, web-tests, the regen # jobs) — required for the React 19 peer conflict; without it `npm ci` # rejects the lockfile ("Missing: yaml@1.10.3 from lock file"). - name: Build web UI (clean, fresh) run: | rm -rf omnigent/server/static/web-ui npm --prefix web ci --legacy-peer-deps npm --prefix web run build # Vite outDir -> omnigent/server/static/web-ui # 2. Tag-driven: the tag must match the version in all three pyprojects # and the core package's `==` sibling-SDK pins, so the lockstep # contract holds. Skipped on a non-tag ref (nothing to compare). - name: Verify tag matches package versions if: startsWith(github.ref, 'refs/tags/v') run: | uv run --no-project python - "${GITHUB_REF_NAME#v}" <<'PY' import sys import tomllib tag = sys.argv[1] # Every package carries the tag's version; every cross-package # dep is an exact `==tag` pin (the lockstep contract). packages = { "pyproject.toml": ("omnigent-client", "omnigent-ui-sdk"), "sdks/python-client/pyproject.toml": ("omnigent",), "sdks/ui/pyproject.toml": ("omnigent-client",), } errors = [] for path, sibling_pins in packages.items(): with open(path, "rb") as f: project = tomllib.load(f)["project"] print(f"{path}: version={project['version']}") if project["version"] != tag: errors.append(f"{path} version {project['version']} does not match tag v{tag}") for name in sibling_pins: pin = f"{name}=={tag}" if pin not in project["dependencies"]: errors.append(f"{path} dependencies missing exact pin {pin!r}") if errors: for e in errors: print(f"::error::{e}") sys.exit(1) PY # 3. Build sdist + wheel for all three into one dist/. The SDKs are # path-deps (not a uv workspace), so each needs its own build. - name: Build sdists + wheels run: | uv build --out-dir dist uv build sdks/python-client --out-dir dist uv build sdks/ui --out-dir dist ls -la dist/ # 4. Metadata sanity check (long-description renders, fields valid). - name: twine check run: uvx twine check dist/* # 5. GATE: the UI bundle must be inside the core wheel; fail loud if # missing/empty. The glob matches only the core wheel (SDK wheels # are omnigent_client-* / omnigent_ui_sdk-*). - name: Assert web-UI bundle shipped in the wheel run: | uv run --no-project python - <<'PY' import glob import sys import zipfile whls = sorted(glob.glob("dist/omnigent-*.whl")) if not whls: sys.exit("no core omnigent wheel found in dist/") whl = whls[-1] names = zipfile.ZipFile(whl).namelist() ui = [n for n in names if "server/static/web-ui/" in n] has_index = any(n.endswith("server/static/web-ui/index.html") for n in ui) print(f"{whl}: {len(ui)} web-ui files, index.html present = {has_index}") sys.exit(0 if (ui and has_index) else "WEB-UI BUNDLE MISSING FROM WHEEL") PY # 6. GATE: the wheels install together and the CLI entry point imports, # resolving deps from public PyPI (catches proxy-only deps). - name: Smoke-install the built wheels run: | uv venv --python 3.12 /tmp/omnigent-smoke uv pip install --python /tmp/omnigent-smoke/bin/python dist/*.whl /tmp/omnigent-smoke/bin/omnigent --version # 7. Persist the built artifacts for inspection. - name: Upload built distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist-omnigent path: dist/ # PUBLISH via OIDC Trusted Publishing (no token; id-token: write granted # above). Attestations stay ON (PEP 740 provenance for a public project). - name: Publish to TestPyPI # Tag pushes + explicit test-pypi dispatches; PR runs never publish. if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.destination == 'test-pypi') uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ # Let a re-run skip already-landed files after a partial publish; # the real-PyPI step omits this so a prod collision fails loud. skip-existing: true - name: Publish to PyPI # Real PyPI only via deliberate dispatch with destination=pypi, # behind the protected `pypi` environment. if: github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi' uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 # default repository-url is pypi.org