From 5814b8c1f0c6ee253e1927b2732f25bae66e1088 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:16:03 -0400 Subject: [PATCH] Install the npm latest dist-tag, and let the first publish come from main Two things about this repository's release topology break the launcher as it was merged. The agent's maintenance releases are cut from branches that never reach main: v0.3.1 is on npm as `latest`, while main still declares 0.3.0. A launcher that installs its own version would therefore hand pip users an older agent than `npm install -g aether-agents@latest` hands npm users -- the exact opposite of what this package promises. So the default install target is now the `latest` dist-tag, and this package's version means only "the launcher released alongside main". `self install --npm-version` and AETHER_AGENT_NPM_VERSION still pin an exact one, and `self doctor` now prints what it would fetch, so the distinction is visible rather than implied. The publish workflow also could not run at all: it demanded a release tag, and no tag cut before this launcher existed contains packages/pypi-cli. A dispatch may now build main, which is how the first publish has to happen; a release still publishes from its own immutable tag, and the tag-equals-version check still applies whenever the ref is a tag. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish-pypi.yml | 37 ++++++++++++------- README.md | 2 +- packages/pypi-cli/README.md | 20 ++++++---- packages/pypi-cli/pyproject.toml | 7 ++-- .../pypi-cli/src/aether_agent/__init__.py | 6 ++- packages/pypi-cli/src/aether_agent/cli.py | 22 ++++++++--- packages/pypi-cli/tests/test_cli.py | 13 ++++--- 7 files changed, 70 insertions(+), 37 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3b232556..0deac01a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -2,8 +2,9 @@ name: Publish PyPI launcher # Ships packages/pypi-cli to PyPI as `aether-agent`: the pip/pipx front door that installs # and runs the npm CLI. Separate from release.yml (which publishes the npm package itself) -# -- own ecosystem, own artifact, own job -- but cut from the same immutable release tag, so -# `pipx install aether-agent` and `npm install -g aether-agents` are the same release. +# -- own ecosystem, own artifact, own job. A release publishes it from that release's own +# immutable tag; the first publish is dispatched from main, because no tag cut before the +# launcher existed contains packages/pypi-cli at all. # # Authentication is PyPI Trusted Publishing (OIDC): no token, nothing to rotate or leak. The # `pypi-production` environment scopes which GitHub identity PyPI will accept; its Trusted @@ -21,8 +22,9 @@ on: workflow_dispatch: inputs: ref: - description: Release tag to build (for example v0.3.0) - required: true + description: Ref to build -- a release tag, or main for the first publish + required: false + default: main type: string dry_run: description: Build and verify without publishing @@ -48,7 +50,7 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name || inputs.ref }} steps: - - name: Checkout immutable release tag + - name: Checkout the release tag, or main for a first publish uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.RELEASE_TAG }} @@ -73,13 +75,24 @@ jobs: # oldest interpreter it claims to support. python-version: '3.10' - - name: Verify the launcher, the npm package, and the tag agree + # The launcher's version must always agree with package.json, and with the tag when + # the ref is one. The first publish is cut from main because no existing tag contains + # packages/pypi-cli at all, so the tag check applies only to a ref that is a tag. + - name: Verify the launcher and the npm package agree + id: version shell: bash run: | set -Eeuo pipefail node packages/sync-version.mjs --check version="$(node -p "require('./package.json').version")" - test "$version" = "${RELEASE_TAG#v}" + if [ "$RELEASE_TAG" = "main" ]; then + test "$GITHUB_EVENT_NAME" = "workflow_dispatch" + echo "building main at $(git rev-parse HEAD) as version ${version}" + else + test "$version" = "${RELEASE_TAG#v}" + echo "ref ${RELEASE_TAG} agrees with package.json ${version}" + fi + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - name: Refuse runtime dependencies working-directory: packages/pypi-cli @@ -98,7 +111,6 @@ jobs: python -m mypy src - name: Build sdist and wheel - id: build shell: bash working-directory: packages/pypi-cli run: | @@ -106,8 +118,7 @@ jobs: python -m pip install --upgrade pip build==1.4.0 twine==6.2.0 python -m build python -m twine check --strict dist/* - version="${RELEASE_TAG#v}" - printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + version="${{ steps.version.outputs.version }}" test -f "dist/aether_agent-${version}.tar.gz" test -f "dist/aether_agent-${version}-py3-none-any.whl" sha256sum dist/* @@ -119,9 +130,9 @@ jobs: set -Eeuo pipefail python -m venv "$RUNNER_TEMP/launcher" "$RUNNER_TEMP/launcher/bin/pip" install --quiet \ - "dist/aether_agent-${{ steps.build.outputs.version }}-py3-none-any.whl" + "dist/aether_agent-${{ steps.version.outputs.version }}-py3-none-any.whl" test "$("$RUNNER_TEMP/launcher/bin/aether-agent" self --version)" \ - = "${{ steps.build.outputs.version }}" + = "${{ steps.version.outputs.version }}" # doctor exits 1 until the npm CLI is present, which is the correct answer on a # clean runner; what is being proved here is that the console script runs at all. "$RUNNER_TEMP/launcher/bin/aether-agent" self doctor || true @@ -146,5 +157,5 @@ jobs: shell: bash run: | set -Eeuo pipefail - echo "Dry run only. Built aether-agent ${{ steps.build.outputs.version }}." + echo "Dry run only. Built aether-agent ${{ steps.version.outputs.version }}." echo "Re-run with dry_run=false to publish." diff --git a/README.md b/README.md index f63ccf94..6ae0e391 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ badge or `npm view aether-agents version` for the npm `latest` dist-tag, and | Install | Version | What it represents | |---|---:|---| | npm `latest` | [![npm latest](https://img.shields.io/npm/v/aether-agents?label=&color=14b8a6)](https://www.npmjs.com/package/aether-agents) | Published package; the badge resolves the live dist-tag. | -| PyPI `aether-agent` | [![PyPI latest](https://img.shields.io/pypi/v/aether-agent?label=&color=3775a9)](https://pypi.org/project/aether-agent/) | Launcher that installs and runs the npm CLI; its version is the agent version it installs. | +| PyPI `aether-agent` | [![PyPI latest](https://img.shields.io/pypi/v/aether-agent?label=&color=3775a9)](https://pypi.org/project/aether-agent/) | Launcher that installs and runs the npm CLI; it fetches the npm `latest` dist-tag unless you pin one. | | `main` source build | **0.3.0** | Current repository source and its 0.3 workflow. | The [release record](docs/releases/2026-08-22.md), diff --git a/packages/pypi-cli/README.md b/packages/pypi-cli/README.md index a8726eab..ad0ec910 100644 --- a/packages/pypi-cli/README.md +++ b/packages/pypi-cli/README.md @@ -23,10 +23,12 @@ Requires **Node 24+** on PATH (the agent's own requirement) and Python 3.10+. `aether` CLI unchanged, and its exit code becomes this process's exit code. `aether-agent code`, `aether-agent doctor`, `aether-agent sessions`, and the slash commands inside the REPL all behave exactly as documented in [`COMMANDS.md`](https://github.com/AetherAI3/aether-agent/blob/main/COMMANDS.md). -- **Installs one known version.** The version of this package *is* the version of the agent it - installs, so `pipx install aether-agent==0.3.0` gets you agent `0.3.0`. Installation goes into a - private prefix under your own data directory, with `--ignore-scripts`, so it needs no - administrator rights and runs no package lifecycle scripts. +- **Installs the same agent the npm route does.** By default it fetches the npm `latest` + dist-tag, so `pipx install aether-agent` and `npm install -g aether-agents@latest` land on the + same agent. Pin a specific one with `self install --npm-version 0.3.1` or + `AETHER_AGENT_NPM_VERSION`. Installation goes into a private prefix under your own data + directory, with `--ignore-scripts`, so it needs no administrator rights and runs no package + lifecycle scripts. - **Defers to an agent you already have.** If `aether` is already on PATH, that is the one it runs. It never installs a second copy behind your back. - **Adds no dependencies.** It shells out to `node` and `npm`, which the agent requires anyway. @@ -38,20 +40,24 @@ is the agent's, `aether-agent self doctor` is the launcher's. ```bash aether-agent self install # install or update the agent CLI -aether-agent self install --npm-version 0.2.1 +aether-agent self install --npm-version 0.3.1 aether-agent self doctor # node, npm, install root, and which aether would run aether-agent self path # print that binary's path aether-agent self uninstall # remove only what this launcher installed ``` -`self install` is optional: the first forwarded command installs the agent if it is missing. +`self install` is optional: the first forwarded command installs the agent if it is missing. Run +it again later to update to the current `latest`. + +This package's own version tracks the repository's `main`, so the launcher and the agent release +together; it is not the version installed. `aether-agent self doctor` prints both. ## Environment | Variable | Effect | | --- | --- | | `AETHER_AGENT_HOME` | Where the launcher keeps its private npm prefix. Defaults to `$XDG_DATA_HOME/aether-agent` (`%LOCALAPPDATA%\aether-agent` on Windows). | -| `AETHER_AGENT_NPM_VERSION` | Install a different version of `aether-agents` than this package declares. Validated before use. | +| `AETHER_AGENT_NPM_VERSION` | Pin the version or dist-tag of `aether-agents` to install, instead of `latest`. Validated before use. | The agent's own variables — `AETHER_API_KEY`, `OLLAMA_HOST`, and the rest — are read by the agent, not by this launcher, and are documented in diff --git a/packages/pypi-cli/pyproject.toml b/packages/pypi-cli/pyproject.toml index ae03da7d..6f9437cb 100644 --- a/packages/pypi-cli/pyproject.toml +++ b/packages/pypi-cli/pyproject.toml @@ -4,9 +4,10 @@ build-backend = "hatchling.build" # The pip/pipx front door to the Aether Agent CLI. Aether Agent itself is the Node package # `aether-agents` on npm; this package installs and launches it. The version below tracks -# that package exactly and is the version this launcher installs by default, so a release -# never ships a launcher pointing at a different agent. packages/sync-version.mjs copies -# package.json's version here, and tests/test_packaging.py fails the build if they drift. +# main's package.json so the two halves of the repository release together -- it is not the +# version installed, which is the npm `latest` dist-tag unless pinned. +# packages/sync-version.mjs copies package.json's version here, and tests/test_packaging.py +# fails the build if they drift. [project] name = "aether-agent" version = "0.3.0" diff --git a/packages/pypi-cli/src/aether_agent/__init__.py b/packages/pypi-cli/src/aether_agent/__init__.py index 80a6907a..48cd3c9d 100644 --- a/packages/pypi-cli/src/aether_agent/__init__.py +++ b/packages/pypi-cli/src/aether_agent/__init__.py @@ -5,8 +5,10 @@ CLI without hand-rolling an npm global install, and every command you type is forwarded to that CLI unchanged. -The version here tracks the npm package exactly, and is the version this launcher installs -by default. +The version here tracks main's npm package version, so the two halves of the repository +release together. It is not the version installed: the launcher fetches the npm `latest` +dist-tag unless you pin one, so `pipx install aether-agent` and +`npm install -g aether-agents@latest` land on the same agent. """ from __future__ import annotations diff --git a/packages/pypi-cli/src/aether_agent/cli.py b/packages/pypi-cli/src/aether_agent/cli.py index dc2c41d4..6622d975 100644 --- a/packages/pypi-cli/src/aether_agent/cli.py +++ b/packages/pypi-cli/src/aether_agent/cli.py @@ -9,9 +9,10 @@ 1. Every argument that is not in the ``self`` namespace is forwarded to the real ``aether`` CLI unchanged, and its exit code is this process's exit code. This launcher never reimplements, filters, or renames an agent command. -2. It installs one known version -- the version of this package -- into a private prefix - that needs no administrator rights, unless an ``aether`` is already on PATH, in which - case that one is used and nothing is installed behind your back. +2. It installs the same agent the documented npm route installs -- the ``latest`` + dist-tag, unless you pin one -- into a private prefix that needs no administrator + rights, unless an ``aether`` is already on PATH, in which case that one is used and + nothing is installed behind your back. Zero runtime dependencies: it shells out to ``node`` and ``npm``, which the agent requires anyway. @@ -55,11 +56,19 @@ def _print_error(message: str) -> None: print(message, file=sys.stderr) +#: What `self install` fetches when nothing is pinned. `latest` rather than this +#: package's own version on purpose: the agent ships maintenance releases from branches +#: that never reach main, so a launcher pinned to main's version would quietly install an +#: older agent than `npm install -g aether-agents@latest` gives. The two install routes +#: must land on the same agent; pin explicitly when you want a fixed one. +DEFAULT_NPM_TAG = "latest" + + def _requested_version() -> str: - """The npm version to install: this package's version unless overridden.""" + """The npm version or dist-tag to install: `latest` unless pinned.""" override = os.environ.get("AETHER_AGENT_NPM_VERSION", "").strip() if not override: - return __version__ + return DEFAULT_NPM_TAG if not VERSION_PATTERN.match(override): _print_error(f"Invalid AETHER_AGENT_NPM_VERSION: {override}") raise SystemExit(2) @@ -197,6 +206,7 @@ def _cmd_doctor(_: argparse.Namespace) -> int: print(f"node v{node} ({supported})") print(f"npm {'found' if shutil.which('npm') else 'not found'}") print(f"install root {install_root()}") + print(f"installs {NPM_PACKAGE}@{_requested_version()}") found = resolve_binary() if found is None: @@ -247,7 +257,7 @@ def _self_parser() -> argparse.ArgumentParser: installer.add_argument( "--npm-version", default=None, - help=f"npm version to install (default {__version__})", + help=f"npm version or dist-tag to install (default {DEFAULT_NPM_TAG})", ) installer.set_defaults(handler=_cmd_install) diff --git a/packages/pypi-cli/tests/test_cli.py b/packages/pypi-cli/tests/test_cli.py index 478c1cfc..42f89063 100644 --- a/packages/pypi-cli/tests/test_cli.py +++ b/packages/pypi-cli/tests/test_cli.py @@ -7,7 +7,7 @@ from pathlib import Path from unittest import mock -from aether_agent import NPM_PACKAGE, __version__, cli +from aether_agent import NPM_PACKAGE, cli AETHER = Path("/bin/aether") MANAGED = Path("/managed/aether") @@ -36,11 +36,14 @@ def test_falls_back_to_a_per_user_directory_needing_no_admin_rights(self) -> Non class TestRequestedVersion(unittest.TestCase): - def test_defaults_to_this_package_version(self) -> None: + def test_defaults_to_the_npm_latest_dist_tag(self) -> None: + # Not this package's version: the agent ships maintenance releases from branches + # that never reach main, so pinning to main's version would install an older agent + # than the documented `npm install -g aether-agents@latest` route. with mock.patch.dict( "os.environ", {"AETHER_AGENT_NPM_VERSION": ""}, clear=False ): - self.assertEqual(cli._requested_version(), __version__) + self.assertEqual(cli._requested_version(), "latest") def test_accepts_an_explicit_override(self) -> None: with mock.patch.dict( @@ -148,7 +151,7 @@ def test_agent_commands_are_never_shadowed_by_the_launcher(self) -> None: class TestSelfNamespace(unittest.TestCase): - def test_install_pins_the_version_this_launcher_declares(self) -> None: + def test_install_targets_the_same_agent_as_the_npm_route(self) -> None: with ( mock.patch.object(cli, "_require_supported_node"), mock.patch.object(cli, "_require", return_value="npm"), @@ -163,7 +166,7 @@ def test_install_pins_the_version_this_launcher_declares(self) -> None: command = run.call_args.args[0] self.assertEqual(command[:2], ["npm", "install"]) self.assertIn("--ignore-scripts", command) - self.assertEqual(command[-1], f"{NPM_PACKAGE}@{__version__}") + self.assertEqual(command[-1], f"{NPM_PACKAGE}@{cli.DEFAULT_NPM_TAG}") def test_install_accepts_an_explicit_npm_version(self) -> None: with (