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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Executable
+43
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user