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 (