feat(ci): nightly prerelease builds — tag-only dev-datestamp cuts from main (#3475)

* feat(ci): nightly prerelease builds — tag-only dev-datestamp cuts from main

Adds nightly-release.yml: every night at 04:30 UTC it walks main to the
newest commit with completed green CI, stamps the lockstep version to
X.Y.Z.devYYYYMMDD, commits the stamp detached on top of that base, and
pushes only the tag via the omnigent-ci App token. Quiet nights (no new
commits since the last nightly tag) and same-day reruns no-op.

Deliberately not the release.yml flow: no release branch, no main bump,
no benchmark gate. Downstream is already dev-quiet: no GitHub release,
no notes, no changelog, no homebrew; images publish the immutable
version tag; update-check ignores dev releases and omni upgrade --pre
opts in. The datestamp is fixed-width because PEP 440 compares the dev
segment as one integer — a wider stamp would sort above every narrower
one forever.

PyPI publishing follows separately via the secure release repo's
scheduled lane.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* feat(ci): nightly consumer update script; drop the stale PyPI hand-off note

scripts/update_nightly.sh resolves the newest vX.Y.Z.devYYYYMMDD tag
(version sorts before date, so the first nightly after a main version
bump outranks all older ones; the 8-digit date requirement screens out
legacy .dev0-style tags) and installs it with uv, pinning the lockstep
trio to that one tagged commit. Idempotent, so it is cron-safe: it
exits fast when the newest nightly is already installed instead of
redoing the web-UI build.

The workflow header no longer claims the secure release repo publishes
nightly tags to PyPI: that lane was dropped, nightlies are consumed
straight from the tag.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* feat(cli): omni upgrade --nightly moves onto the newest nightly tag

Nightlies are vX.Y.Z.devYYYYMMDD git tags that never reach the package
index, so the flag answers 'is there something newer' from the repo's
tags (git ls-remote + PEP 440 max, so the first nightly after a main
version bump outranks all older ones) and reinstalls with a git spec
pinned to that tag, per installer (uv/pipx/pip/poetry). It dispatches
before the VCS-vs-registry split: a registry install hops onto the
channel, and a VCS install pinned to an older nightly moves tags
instead of re-pulling its pinned ref. Same drain/stop, --check, and
probe-the-disk verification contracts as the release path.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
This commit is contained in:
Dhruv Gupta
2026-08-03 12:08:41 -07:00
committed by GitHub
parent b2b1002ee4
commit cfb431c20c
5 changed files with 791 additions and 1 deletions
+255
View File
@@ -0,0 +1,255 @@
# Cut the nightly prerelease build: a stamped, tagged snapshot of main.
#
# Every night (or on manual dispatch) this picks the newest commit on main
# with green CI, stamps the lockstep version to `X.Y.Z.devYYYYMMDD` (today's
# UTC date appended to main's `X.Y.Z.dev0` line), commits that stamp DETACHED
# on top of the base commit, tags it `vX.Y.Z.devYYYYMMDD`, and pushes ONLY the
# tag with the omnigent-ci App token (GITHUB_TOKEN-pushed tags fire no
# workflows; the image build hangs off the tag push).
#
# Deliberately NOT the release.yml flow: no release/vX.Y branch is created,
# bump-version.yml is never dispatched (a nightly must not walk main's
# version), and there is no benchmark gate (benchmark.yml already measures
# main nightly). Downstream is already quiet for dev tags: github-release.yml
# and draft-release-notes.yml skip `*dev[0-9]*` tags, update-check ignores
# dev releases, a default `pip install` never resolves them, and
# oss-publish-images.yml publishes the immutable
# `:vX.Y.Z.devYYYYMMDD` image (only `:latest-rc` follows it, by design).
#
# The datestamp is FIXED-WIDTH (YYYYMMDD, one nightly per UTC day): PEP 440
# compares the dev segment as a plain integer, so a longer stamp would sort
# above every shorter one forever. A same-day re-run finds the tag and no-ops.
#
# Nightlies are NOT published to PyPI. Consumers install straight from the
# tag with uv (scripts/update_nightly.sh resolves and installs the newest
# one), which pins all lockstep packages to the tagged commit.
name: Nightly Release
on:
schedule:
# 04:30 UTC: after the 00:00 UTC scheduled suites drain, well before the
# 07:00 UTC image rebuild (its concurrency is per-SHA, so a tag build
# racing it would cold-race the layer cache).
- cron: "30 4 * * *"
workflow_dispatch:
inputs:
dry_run:
description: "Plan only: print the base commit and version, push nothing."
required: false
type: boolean
default: true
# Nothing here writes with GITHUB_TOKEN; the tag push uses the App token.
permissions:
contents: read
# Share release.yml's group: a nightly cut must never interleave with a real
# release dispatch.
concurrency:
group: release
cancel-in-progress: false
jobs:
plan:
if: github.repository == 'omnigent-ai/omnigent'
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
base_sha: ${{ steps.resolve.outputs.base_sha }}
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
should_cut: ${{ steps.resolve.outputs.should_cut }}
steps:
# Manual dispatches are maintainer-only, same rule as release.yml.
# Scheduled runs have no meaningful actor and skip the check.
- name: Require admin/maintain role (dispatch only)
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
role="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .role_name)"
case "$role" in
admin|maintain)
echo "Dispatcher ${ACTOR} has role ${role} — authorized." | tee -a "$GITHUB_STEP_SUMMARY" ;;
*)
echo "::error::Nightly dispatches require the admin or maintain role (dispatcher ${ACTOR} has '${role}')."
exit 1 ;;
esac
- name: Checkout main history and tags
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Resolve base commit, version, and whether to cut
id: resolve
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# Boolean inputs arrive as strings on CLI dispatches and are empty
# on schedule — gate in shell, not in `if:` expressions. A schedule
# fire is always a real run; a dispatch is dry unless explicitly not.
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DRY_RUN_INPUT" != "false" ]; then
dry_run=true
else
dry_run=false
fi
# Walk main newest-first to the first commit whose CI is complete
# and green. A fixed-hour cron can't demand HEAD be green (the last
# merge of the day is often mid-CI); walking back keeps the nightly
# cadence without ever building a red commit. Check runs from this
# workflow's own runs are excluded (a schedule run parks a pending
# check on the HEAD it triggered from — it must not mask HEAD).
base_sha=""
for sha in $(git rev-list -n 20 origin/main); do
own_runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/nightly-release.yml/runs?head_sha=${sha}&per_page=100" \
--jq '.workflow_runs[].id' 2>/dev/null | paste -sd, -)"
runs="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?per_page=100" \
--paginate --jq '.check_runs[] | [.name, .status, .conclusion // "-", .details_url] | @tsv')"
runs="$(printf '%s' "$runs" | awk -F'\t' -v rel="$own_runs" '
BEGIN { n=split(rel, a, ","); for (i=1; i<=n; i++) if (a[i]!="") own[a[i]]=1 }
{ o=0; for (r in own) if (index($4, "/runs/" r "/")) { o=1; break }
if (!o) print $1 "\t" $2 "\t" $3 }')"
total="$(printf '%s' "$runs" | grep -c . || true)"
pending="$(printf '%s' "$runs" | awk -F'\t' '$2 != "completed"' || true)"
bad="$(printf '%s' "$runs" | awk -F'\t' '$3 ~ /^(failure|timed_out|action_required|startup_failure)$/' || true)"
if [ "$total" -gt 0 ] && [ -z "$bad" ] && [ -z "$pending" ]; then
base_sha="$sha"
break
fi
echo "skipping ${sha}: $([ "$total" -eq 0 ] && echo 'no check runs' || { [ -n "$bad" ] && echo 'failing checks' || echo 'checks still running'; })"
done
should_cut=false
version=""
tag=""
reason=""
if [ -z "$base_sha" ]; then
reason="no commit with completed green CI in the newest 20 on main"
else
# Main must carry the `X.Y.Z.dev0` marker (the repo's versioning
# invariant). The nightly replaces the dev segment with today's
# UTC date; anything else on main is a broken state to fail loudly on.
main_version="$(git show "${base_sha}:pyproject.toml" | sed -n 's/^version = "\(.*\)"$/\1/p' | head -1)"
if ! [[ "$main_version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)\.dev0$ ]]; then
echo "::error::main's version at ${base_sha} is '${main_version}', expected X.Y.Z.dev0 — refusing to derive a nightly version."
exit 1
fi
version="${BASH_REMATCH[1]}.dev$(date -u +%Y%m%d)"
tag="v${version}"
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
reason="tag ${tag} already exists (nightly already cut today)"
else
# Skip quiet nights: the newest nightly tag's commit is the
# stamp on top of its base, so parent = the base it built. If
# our base is that commit (or older, after a red-HEAD walk),
# there is nothing new to ship.
last_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short)' 'refs/tags/v[0-9]*' \
| grep -E '\.dev[0-9]{8}$' | head -1 || true)"
if [ -n "$last_tag" ] && { [ "$base_sha" = "$(git rev-parse "${last_tag}^")" ] \
|| git merge-base --is-ancestor "$base_sha" "$(git rev-parse "${last_tag}^")"; }; then
reason="no new commits on main since ${last_tag}"
elif [ "$dry_run" = "true" ]; then
reason="dry run — would cut ${tag} from ${base_sha}"
else
should_cut=true
fi
fi
fi
{
echo "## Nightly plan"
echo ""
echo "| | |"
echo "| --- | --- |"
echo "| Base commit | \`${base_sha:-—}\` |"
echo "| Version | \`${version:-—}\` |"
echo "| Cutting | ${should_cut} |"
[ -n "$reason" ] && echo "| Reason | ${reason} |"
} >> "$GITHUB_STEP_SUMMARY"
{
echo "base_sha=${base_sha}"
echo "version=${version}"
echo "tag=${tag}"
echo "should_cut=${should_cut}"
} >> "$GITHUB_OUTPUT"
cut:
needs: plan
if: needs.plan.outputs.should_cut == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Clean public resolution for `uv lock` — the committed lockfile must
# reference https://pypi.org/simple (never a proxy).
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Mint App token (omnigent)
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
owner: ${{ github.repository_owner }}
repositories: omnigent
- name: Checkout base commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.plan.outputs.base_sha }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false
- name: Stamp the lockstep version
env:
VERSION: ${{ needs.plan.outputs.version }}
run: |
set -euo pipefail
uv run --no-project --python 3.12 --with packaging \
python scripts/update_versions.py pre-release --new-version "$VERSION"
uv lock
uv run --no-project --python 3.12 --with packaging \
python scripts/update_versions.py check --expect "$VERSION"
- name: Commit, tag, and push the tag
env:
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ needs.plan.outputs.tag }}
run: |
set -euo pipefail
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# Stage everything the stamp touched: update_versions.py owns the
# file set (same staging as release.yml and bump-version.yml).
git add -A
git commit -s -m "nightly: ${TAG}"
git tag "$TAG"
# Tag only — the stamp commit stays off every branch, reachable via
# the tag. The App token push fires the tag-triggered workflows.
push_url="https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push "$push_url" "refs/tags/${TAG}"
echo "Pushed ${TAG} at $(git rev-parse HEAD) (base $(git rev-parse HEAD^))." \
| tee -a "$GITHUB_STEP_SUMMARY"
+173
View File
@@ -4440,6 +4440,142 @@ def _upgrade_vcs_install(
click.echo("Re-pulled the git ref. Run `omni upgrade --check` to confirm.")
def _upgrade_to_nightly(
info: _InstalledWheelInfo,
*,
check_only: bool,
force: bool,
extra_overrides: tuple[str, ...],
dry_run: bool,
) -> None:
"""Move this install onto the newest nightly build (``--nightly``).
Nightlies are ``vX.Y.Z.devYYYYMMDD`` git tags cut from the newest green
commit on main; they never reach the package index. So "is there
something newer" is answered by the repo's tags, and the upgrade is a
git-pinned reinstall regardless of how omnigent was first installed:
a registry install hops onto the channel, an older nightly moves
forward, and a same-version rerun no-ops. The registry shapes that
cannot record extras (pip / ``uv pip``) are refused with a manual
command, mirroring the index upgrade path.
:param info: Installed-distribution metadata.
:param check_only: Only report; exit non-zero when running without
``--check`` would change the install.
:param force: Stop in-flight sessions immediately instead of draining.
:param extra_overrides: Extras supplied by ``omni upgrade --extra``.
:param dry_run: Print the command and exit without running it.
"""
import importlib.metadata
from packaging.version import InvalidVersion, Version
from omnigent.update_check import (
_NIGHTLY_REPO_URL,
_build_nightly_upgrade_suggestion,
_latest_nightly_version,
_probe_installed_distribution,
_run_upgrade_command,
_uv_tool_receipt_path,
)
current = importlib.metadata.version("omnigent")
target = _latest_nightly_version()
if target is None:
raise click.ClickException(
"Couldn't find a nightly tag. Check your connection to github.com "
"(nightlies are git tags, not index releases); if the nightly lane "
"only just landed, the first tag appears after the next 04:30 UTC cut."
)
try:
up_to_date = Version(current) == Version(target)
except InvalidVersion:
up_to_date = current == target
if up_to_date:
click.echo(f"omnigent is already on the newest nightly (v{current}).")
return
click.echo(f"Newest nightly: v{target} (currently v{current}).")
if check_only:
# Non-zero means "running --nightly would change the install", the
# same contract as the release path's --check. SystemExit rather than
# ctx.exit for the standalone_mode=False reason documented there.
raise SystemExit(1)
# Same safety rule as the index path: pip / `uv pip` do not record which
# extras were requested, so a nightly hop cannot preserve them. Only
# registry shapes are refused; a VCS install's recorded URL is already
# the source of truth (matching _build_upgrade_suggestion's split).
if info.vcs_url is None:
manual = f"git+{_NIGHTLY_REPO_URL}@v{target}"
if info.detected_installer == "pip":
click.echo(
"omnigent was installed with pip, and pip does not record which "
"extras were requested. `omni upgrade --nightly` cannot preserve "
"them safely, so install the nightly manually:\n\n"
f" pip install --force-reinstall '{manual}'\n"
" # or, if you need extras:\n"
f" pip install --force-reinstall '{manual}#egg=omnigent[your,extras,here]'"
)
raise SystemExit(0)
if info.detected_installer == "uv" and _uv_tool_receipt_path() is None:
click.echo(
"omnigent was installed with `uv pip`, not `uv tool install`. "
"`uv pip` does not record which extras were requested, so "
"`omni upgrade --nightly` cannot preserve them safely. Install "
"the nightly manually:\n\n"
f" uv pip install --force-reinstall '{manual}'\n"
" # or, if you need extras:\n"
f" uv pip install --force-reinstall '{manual}#egg=omnigent[your,extras,here]'"
)
raise SystemExit(0)
suggestion = _build_nightly_upgrade_suggestion(info, target, extra_overrides=extra_overrides)
if not suggestion.runnable:
raise click.ClickException(
f"No automatic upgrade command is known for this install. {suggestion.command}."
)
if dry_run:
extras = sorted(set(extra_overrides or info.extras))
click.echo(
f"Detected installer: {info.detected_installer or info.installer}\n"
f"Detected extras: {', '.join(extras) if extras else '(none)'}\n"
f"Would run: {suggestion.command}"
)
return
_drain_and_stop_local_server(force=force)
console = Console()
code = _run_upgrade_command(suggestion.command, console)
if code != 0:
raise click.ClickException(
f"Upgrade command exited with status {code}; your previous install is intact."
)
# Same trust-the-disk verification as the release path: re-read the
# version in a fresh subprocess instead of believing the exit code.
new_version, _ = _probe_installed_distribution()
if new_version == target:
click.echo(
f"✓ Upgraded to nightly v{target}. Re-run your command; the local "
"server will start on the new version."
)
return
if new_version is None:
click.echo(
"Ran the upgrade command, but couldn't confirm the installed version. "
"Run `omni upgrade --nightly --check` to verify."
)
return
raise click.ClickException(
f"The upgrade command ran but omnigent is still v{new_version} (expected "
f"v{target}). Try the command directly: {suggestion.command}"
)
@cli.command("upgrade")
@click.option(
"--check",
@@ -4460,6 +4596,13 @@ def _upgrade_vcs_install(
help="Consider pre-releases (e.g. release candidates), and pass the "
"installer's allow-pre-releases flag. Useful for validating a TestPyPI rc.",
)
@click.option(
"--nightly",
is_flag=True,
help="Upgrade to the newest nightly build: a vX.Y.Z.devYYYYMMDD git tag "
"installed straight from GitHub with your installer (needs git, plus "
"Node 22 and pnpm to build the web UI). Skips the package index.",
)
@click.option(
"--extra",
"extra_overrides",
@@ -4481,6 +4624,7 @@ def upgrade(
check_only: bool,
force: bool,
pre: bool,
nightly: bool,
extra_overrides: tuple[str, ...],
target_version: str | None,
dry_run: bool,
@@ -4497,6 +4641,11 @@ def upgrade(
stop them immediately. Pass ``--pre`` to consider pre-releases (rc /
beta). Pass ``--extra`` to keep or add extras that the installer did not
record. Use ``--target-version`` when validating a specific release.
Pass ``--nightly`` to move onto the newest nightly build instead:
nightlies are git tags that never reach the index, so that path
reinstalls from GitHub pinned to the newest nightly tag (``--extra``,
``--dry-run``, and ``--check`` compose with it; ``--pre`` and
``--target-version`` do not apply).
Requires the install to have come from a tool that records extras:
``uv tool install`` or ``pipx install``. ``pip`` does not record
@@ -4508,6 +4657,8 @@ def upgrade(
with status 1 when a newer release exists.
:param force: Stop in-flight sessions immediately rather than draining.
:param pre: Consider pre-releases and allow the installer to fetch them.
:param nightly: Upgrade to the newest nightly git tag instead of the
latest index release.
:param extra_overrides: Extras requested via ``--extra``.
:param target_version: Pin the upgrade to this version.
:param dry_run: Print the command and exit without running it.
@@ -4527,6 +4678,14 @@ def upgrade(
fetch_latest_version,
)
if nightly and target_version:
raise click.UsageError(
"--nightly and --target-version are mutually exclusive: --nightly "
"always tracks the newest nightly tag. To install a specific "
"nightly, run your installer against that tag directly, e.g. "
"`uv tool install --force git+https://github.com/omnigent-ai/omnigent@v0.8.0.dev20260801`."
)
# Source checkout / editable install — there's no released wheel to
# swap in place; the correct update path is git, not a reinstall.
if _find_repo_root() is not None:
@@ -4544,6 +4703,20 @@ def upgrade(
"This is an editable install — update it with `git pull`, not `omni upgrade`."
)
# Nightly channel: resolved from git tags, not the index, and applies to
# every install shape, so it dispatches before the VCS-vs-registry split
# (a VCS install pinned to an older nightly tag must move tags, not
# re-pull its pinned ref).
if nightly:
_upgrade_to_nightly(
info,
check_only=check_only,
force=force,
extra_overrides=extra_overrides,
dry_run=dry_run,
)
return
# A git/VCS install tracks a moving git ref, not a PyPI release. Its
# version string (a frozen ``0.1.0`` on an unbumped ``main``, say) is NOT
# comparable to the latest PyPI release: comparing them reports a build
+101
View File
@@ -1563,6 +1563,107 @@ def _build_upgrade_suggestion(
)
# Canonical public repo for nightly builds. Nightlies are git tags consumed
# straight from GitHub (they never reach an index), so the channel lives
# upstream by definition: fork installs also upgrade onto upstream nightlies.
_NIGHTLY_REPO_URL = "https://github.com/omnigent-ai/omnigent"
def _newest_nightly_version(ls_remote_output: str) -> str | None:
"""Pick the newest nightly version from ``git ls-remote --tags`` output.
Nightly tags are strictly ``vX.Y.Z.devYYYYMMDD``; the 8-digit datestamp
requirement screens out legacy ``.dev0``-style tags, and rc/final tags
and annotated-tag peel lines (``^{}``) never match. Ordering is PEP 440,
so the first nightly after a main version bump outranks all older ones.
:param ls_remote_output: Raw ``git ls-remote`` stdout (tab-separated
``<sha> refs/tags/<name>`` lines).
:returns: The newest nightly version without the leading ``v`` (e.g.
``"0.8.0.dev20260804"``), or ``None`` when no nightly tag exists.
"""
import re
from packaging.version import InvalidVersion, Version
pattern = re.compile(r"^v(\d+\.\d+\.\d+\.dev\d{8})$")
versions: list[Version] = []
for line in ls_remote_output.splitlines():
match = pattern.match(line.rpartition("refs/tags/")[2].strip())
if match is None:
continue
with contextlib.suppress(InvalidVersion):
versions.append(Version(match.group(1)))
return str(max(versions)) if versions else None
def _latest_nightly_version(repo_url: str = _NIGHTLY_REPO_URL) -> str | None:
"""Resolve the newest nightly version from the repo's tags, or ``None``.
Best-effort ``git ls-remote`` with a tight timeout, like
``_remote_git_head``: any failure (offline, missing ``git``, no nightly
tags yet) yields ``None`` so the caller prints an actionable message
instead of crashing.
:param repo_url: Repo to list tags from; defaults to the canonical repo.
:returns: e.g. ``"0.8.0.dev20260804"``, or ``None``.
"""
try:
result = subprocess.run(
["git", "ls-remote", "--tags", repo_url, "refs/tags/v*.dev*"],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SECONDS,
check=True,
)
except (subprocess.SubprocessError, OSError):
return None
return _newest_nightly_version(result.stdout)
def _build_nightly_upgrade_suggestion(
info: _InstalledWheelInfo,
nightly_version: str,
*,
extra_overrides: tuple[str, ...] = (),
) -> _UpgradeSuggestion:
"""Build the command that moves this install onto a nightly tag.
Nightlies are git tags, not index releases, so every installer gets a
git-pinned spec for the canonical repo. That includes registry installs
(this is how an index install hops onto the nightly channel) and VCS
installs of a fork (nightly tags only exist upstream). Extras follow
the same union rule as :func:`_build_upgrade_suggestion`; the CLI
refuses the registry install shapes that cannot record extras (pip /
``uv pip``) before building.
:param info: Metadata from ``_read_installed_wheel_info``.
:param nightly_version: Target version without the leading ``v``.
:param extra_overrides: Extras supplied with ``omni upgrade --extra``.
:returns: See :func:`_build_upgrade_suggestion`.
"""
extras = sorted(set(info.extras) | set(extra_overrides))
spec = f"git+{_NIGHTLY_REPO_URL}@v{nightly_version}"
if extras:
# Same egg-fragment shape the VCS upgrade path uses for extras.
spec = f"{spec}#egg={_package_spec(extras=extras)}"
installer = info.detected_installer or info.installer
if installer == "uv":
# --force: replacing a registry install with a git-sourced one (or
# hopping between tags) is an overwrite, not an upgrade, in uv's model.
return _UpgradeSuggestion(command=f"uv tool install --force {spec}", runnable=True)
if installer == "pipx":
return _UpgradeSuggestion(command=f"pipx install --force {spec}", runnable=True)
if installer == "pip" or (installer is None and info.vcs_url is not None):
return _UpgradeSuggestion(
command=f"{_pip_invocation()} install --force-reinstall {spec}", runnable=True
)
if installer == "poetry":
return _UpgradeSuggestion(command=f"poetry add --force {spec}", runnable=True)
# Unknown installer on a registry install: don't guess the tool.
return _UpgradeSuggestion(command=f"install {_DIST_NAME} from {spec}", runnable=False)
def _run_upgrade_command(command: str, console: Console) -> int:
"""Run the upgrade command in a foreground subprocess.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Install or update the omnigent nightly build.
#
# Nightlies are datestamped prerelease tags (vX.Y.Z.devYYYYMMDD) cut from
# the newest green commit on main by .github/workflows/nightly-release.yml
# (about 04:30 UTC). This script resolves the newest nightly tag and
# installs it with uv, which pins the whole install (omnigent,
# omnigent-client, omnigent-ui-sdk) to that one tagged commit.
#
# Requirements: git, uv, and Node.js 22+ with pnpm (the wheel build
# compiles the web UI and fails with an actionable message if they are
# missing).
#
# Idempotent: exits fast when the newest nightly is already installed,
# so it is safe to run from cron, e.g. daily at 9am:
# 0 9 * * * bash /path/to/update_nightly.sh >> "$HOME/.omnigent-nightly.log" 2>&1
set -euo pipefail
REPO="${OMNIGENT_REPO:-https://github.com/omnigent-ai/omnigent}"
# Newest nightly tag: strictly vX.Y.Z.devYYYYMMDD (the 8-digit date also
# screens out legacy .dev0-style tags). Version sorts before date, so the
# first nightly after a main version bump outranks all older ones.
tag=$(git ls-remote --tags --sort=-v:refname "$REPO" 'refs/tags/v*.dev*' \
| awk -F'refs/tags/' '{print $2}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]{8}$' \
| sed -n 1p)
if [ -z "$tag" ]; then
echo "update_nightly: no nightly tags found in ${REPO}" >&2
exit 1
fi
# Skip the (slow, web-UI-building) reinstall when already current.
if command -v omnigent >/dev/null 2>&1 \
&& omnigent --version 2>/dev/null | grep -qF "${tag#v}"; then
echo "update_nightly: already on ${tag}"
exit 0
fi
echo "update_nightly: installing ${tag}"
uv tool install --force "omnigent @ git+${REPO}@${tag}"
omnigent --version
+219 -1
View File
@@ -9,7 +9,11 @@ from click.testing import CliRunner
from omnigent.cli import cli
from omnigent.host.local_server import LocalServerInfo
from omnigent.update_check import _InstalledWheelInfo
from omnigent.update_check import (
_build_nightly_upgrade_suggestion,
_InstalledWheelInfo,
_newest_nightly_version,
)
def _uv_registry_info() -> _InstalledWheelInfo:
@@ -528,3 +532,217 @@ def test_upgrade_git_confirmed_behind_but_repull_noop_fails(
assert result.exit_code != 0, result.output
assert "still at aaaaaaaaa" in result.output
assert "✓ Updated" not in result.output
def _nightly_ls_remote(*names: str) -> str:
"""Synthesize ``git ls-remote --tags`` output for the given tag names."""
sha = "0" * 40
return "".join(f"{sha}\trefs/tags/{name}\n" for name in names)
def test_newest_nightly_version_filters_and_sorts() -> None:
"""Only vX.Y.Z.devYYYYMMDD tags count; PEP 440 order survives version bumps."""
out = _nightly_ls_remote(
"v0.4.0.dev0", # legacy stray: no 8-digit datestamp
"v0.8.0rc1", # rc: not a nightly
"v0.8.0", # final: not a nightly
"v0.8.0.dev20260731",
"v0.8.0.dev20260801",
"v0.8.0.dev20260801^{}", # annotated-tag peel line
"v0.9.0.dev20260801",
"v0.10.0.dev20260101", # older date but newer version: wins
)
assert _newest_nightly_version(out) == "0.10.0.dev20260101"
def test_newest_nightly_version_none_when_no_nightlies() -> None:
"""No nightly-shaped tags (or no output at all) → None."""
assert _newest_nightly_version("") is None
assert _newest_nightly_version(_nightly_ls_remote("v0.8.0", "v0.8.0rc1")) is None
def test_nightly_suggestion_shapes_per_installer() -> None:
"""Every installer maps to a git spec pinned to the nightly tag."""
version = "0.9.0.dev20260804"
spec = f"git+https://github.com/omnigent-ai/omnigent@v{version}"
def info_for(installer: str | None) -> _InstalledWheelInfo:
return _InstalledWheelInfo(
install_time_epoch=0.0,
installer=installer,
vcs_url=None,
commit_sha=None,
is_editable=False,
package_version="0.1.0",
detected_installer=installer,
)
uv = _build_nightly_upgrade_suggestion(info_for("uv"), version)
assert (uv.command, uv.runnable) == (f"uv tool install --force {spec}", True)
pipx = _build_nightly_upgrade_suggestion(info_for("pipx"), version)
assert (pipx.command, pipx.runnable) == (f"pipx install --force {spec}", True)
pip = _build_nightly_upgrade_suggestion(info_for("pip"), version)
assert pip.runnable and pip.command.endswith(f"install --force-reinstall {spec}")
unknown = _build_nightly_upgrade_suggestion(info_for("conda"), version)
assert not unknown.runnable and spec in unknown.command
def test_upgrade_nightly_up_to_date(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
"""Installed version == newest nightly → no-op, nothing stopped or run."""
monkeypatch.setattr("omnigent.update_check._latest_nightly_version", lambda: "0.1.0")
def _must_not_run(*_a: object, **_k: object) -> int:
raise AssertionError("upgrade command ran while already on the newest nightly")
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
result = CliRunner().invoke(cli, ["upgrade", "--nightly"])
assert result.exit_code == 0, result.output
assert "already on the newest nightly" in result.output
def test_upgrade_nightly_installs_pinned_tag(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""A newer nightly → runs the git-pinned installer command and verifies."""
monkeypatch.setattr(
"omnigent.update_check._latest_nightly_version", lambda: "0.2.0.dev20260804"
)
ran: list[str] = []
def _run(command: str, _console: object) -> int:
ran.append(command)
return 0
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _run)
monkeypatch.setattr(
"omnigent.update_check._probe_installed_distribution",
lambda: ("0.2.0.dev20260804", None),
)
result = CliRunner().invoke(cli, ["upgrade", "--nightly"])
assert result.exit_code == 0, result.output
assert ran == [
"uv tool install --force git+https://github.com/omnigent-ai/omnigent@v0.2.0.dev20260804"
]
assert "Upgraded to nightly v0.2.0.dev20260804" in result.output
def test_upgrade_nightly_check_exits_nonzero(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""``--nightly --check`` with a different nightly → report and exit 1, no upgrade."""
monkeypatch.setattr(
"omnigent.update_check._latest_nightly_version", lambda: "0.2.0.dev20260804"
)
def _must_not_run(*_a: object, **_k: object) -> int:
raise AssertionError("--check must not run the upgrade")
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
result = CliRunner().invoke(cli, ["upgrade", "--nightly", "--check"])
assert result.exit_code == 1, result.output
assert "Newest nightly: v0.2.0.dev20260804" in result.output
def test_upgrade_nightly_no_tags_is_actionable(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""No nightly tag reachable → a clear error, not a crash or a PyPI fallback."""
monkeypatch.setattr("omnigent.update_check._latest_nightly_version", lambda: None)
result = CliRunner().invoke(cli, ["upgrade", "--nightly"])
assert result.exit_code != 0, result.output
assert "Couldn't find a nightly tag" in result.output
def test_nightly_suggestion_preserves_and_unions_extras() -> None:
"""Receipt extras union with --extra overrides, as an egg fragment on the spec."""
info = _InstalledWheelInfo(
install_time_epoch=0.0,
installer="uv",
vcs_url=None,
commit_sha=None,
is_editable=False,
package_version="0.1.0",
detected_installer="uv",
extras=("all",),
)
suggestion = _build_nightly_upgrade_suggestion(
info, "0.9.0.dev20260804", extra_overrides=("server",)
)
assert suggestion.runnable
assert suggestion.command == (
"uv tool install --force "
"git+https://github.com/omnigent-ai/omnigent@v0.9.0.dev20260804#egg=omnigent[all,server]"
)
def test_upgrade_nightly_refuses_registry_pip(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""A registry pip install → manual git command, exit 0, installer never runs."""
pip_info = _InstalledWheelInfo(
install_time_epoch=0.0,
installer="pip",
vcs_url=None,
commit_sha=None,
is_editable=False,
package_version="0.1.0",
detected_installer="pip",
)
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", lambda: pip_info)
monkeypatch.setattr(
"omnigent.update_check._latest_nightly_version", lambda: "0.2.0.dev20260804"
)
def _must_not_run(*_a: object, **_k: object) -> int:
raise AssertionError("refused install shape must not run the installer")
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
result = CliRunner().invoke(cli, ["upgrade", "--nightly"])
assert result.exit_code == 0, result.output
assert "install the nightly manually" in result.output
assert "git+https://github.com/omnigent-ai/omnigent@v0.2.0.dev20260804" in result.output
def test_upgrade_nightly_dry_run_prints_without_running(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""``--nightly --dry-run`` prints the command and exits before any side effects."""
monkeypatch.setattr(
"omnigent.update_check._latest_nightly_version", lambda: "0.2.0.dev20260804"
)
def _must_not_run(*_a: object, **_k: object) -> int:
raise AssertionError("--dry-run must not run the upgrade")
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
result = CliRunner().invoke(cli, ["upgrade", "--nightly", "--dry-run"])
assert result.exit_code == 0, result.output
assert (
"Would run: uv tool install --force "
"git+https://github.com/omnigent-ai/omnigent@v0.2.0.dev20260804" in result.output
)
def test_upgrade_nightly_rejects_target_version(
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
) -> None:
"""``--nightly`` and ``--target-version`` are mutually exclusive flags."""
result = CliRunner().invoke(cli, ["upgrade", "--nightly", "--target-version", "0.9.0"])
assert result.exit_code == 2, result.output
assert "mutually exclusive" in result.output