From e362abd107b15ddb20bc5e6001a6dc14e454dc09 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 23 Sep 2026 14:07:49 -0700 Subject: [PATCH 1/2] Use published Core wheels for host and worker runtimes --- .github/workflows/core-wheels.yml | 81 ++++++++++++++ .github/workflows/python-checks.yml | 11 ++ amplifier_web/runtime_deps/pyproject.toml | 9 +- amplifier_web/runtime_environment.py | 29 ++++- docs/validation/release-qualification.md | 35 +++++++ pyproject.toml | 6 +- scripts/release_qualification.py | 29 ++++- scripts/work_profile/pyproject.toml | 4 +- scripts/work_profile/uv.lock | 20 ++-- tests/test_core_binary.py | 122 ++++++++++++++++++++++ tests/test_release_qualification.py | 48 ++++++++- uv.lock | 16 +-- 12 files changed, 380 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/core-wheels.yml create mode 100644 tests/test_core_binary.py diff --git a/.github/workflows/core-wheels.yml b/.github/workflows/core-wheels.yml new file mode 100644 index 000000000..aa37b5b97 --- /dev/null +++ b/.github/workflows/core-wheels.yml @@ -0,0 +1,81 @@ +name: Published Core wheels +on: + workflow_call: + secrets: + LOOP_LIVE_CI_READ_KEY: + required: true +permissions: + contents: read +jobs: + runtime: + # Called only by the explicitly selected manual qualification mode. + if: github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: false + - name: Install and record the released host Core wheel + run: | + uv sync --locked --group dev + python scripts/release_qualification.py core-wheel --python .venv/bin/python --destination "$RUNNER_TEMP/host-core-wheel.json" + uv run --no-sync pytest tests/test_core_binary.py tests/test_runtime_updates.py tests/test_release_qualification.py tests/test_standalone.py -q + - name: Resolve the declared loop-live source + id: runtime-dependency + run: | + python -c 'import tomllib; p=tomllib.load(open("amplifier_web/runtime_deps/pyproject.toml", "rb")); print("revision=" + p["tool"]["uv"]["sources"]["amplifier-module-loop-live"]["rev"])' >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + repository: microsoft/amplifier-module-loop-live + ref: ${{ steps.runtime-dependency.outputs.revision }} + path: .ci/loop-live + ssh-key: ${{ secrets.LOOP_LIVE_CI_READ_KEY }} + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: microsoft/amplifier-bundle-recipes + ref: main + path: .ci/recipes + persist-credentials: false + - name: Resolve current components and install the released Core wheel + run: | + python -m pip install maturin + python scripts/release_qualification.py runtime-project --runtime "$RUNNER_TEMP/core-runtime" + uv add --project "$RUNNER_TEMP/core-runtime" --frozen --editable "$GITHUB_WORKSPACE/.ci/loop-live" + uv add --project "$RUNNER_TEMP/core-runtime" --frozen pytest pytest-asyncio 'amplifier-module-tool-delegate @ git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate' + uv lock --project "$RUNNER_TEMP/core-runtime" --refresh --upgrade + python scripts/release_qualification.py cache-key --runtime "$RUNNER_TEMP/core-runtime" + uv sync --project "$RUNNER_TEMP/core-runtime" --locked + python scripts/release_qualification.py runtime-snapshot --runtime "$RUNNER_TEMP/core-runtime" + - name: Qualify real runtime behavior without model calls + run: | + UNIFIED_RUNTIME_PYTHON="$RUNNER_TEMP/core-runtime/.venv/bin/python" WARM_RECIPES_PATH="$GITHUB_WORKSPACE/.ci/recipes" "$RUNNER_TEMP/core-runtime/.venv/bin/python" -m pytest tests/test_app_guidance.py tests/test_host_children.py tests/test_host_components.py tests/test_runtime_module_cache.py -q + cp "$RUNNER_TEMP/core-runtime/qualified-runtime.json" "$RUNNER_TEMP/before-tests.json" + python scripts/release_qualification.py runtime-snapshot --runtime "$RUNNER_TEMP/core-runtime" + cmp "$RUNNER_TEMP/before-tests.json" "$RUNNER_TEMP/core-runtime/qualified-runtime.json" + git rev-parse HEAD > "$RUNNER_TEMP/app-revision.txt" + - name: Retain only dependency and binary qualification receipts + if: always() + uses: actions/upload-artifact@v4 + with: + name: core-wheels-${{ matrix.os }} + path: | + ${{ runner.temp }}/app-revision.txt + ${{ runner.temp }}/host-core-wheel.json + ${{ runner.temp }}/core-runtime/pyproject.toml + ${{ runner.temp }}/core-runtime/uv.lock + ${{ runner.temp }}/core-runtime/build-constraints.txt + ${{ runner.temp }}/core-runtime/build-identity.json + ${{ runner.temp }}/core-runtime/qualified-runtime.json + if-no-files-found: error diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 1d350b556..506b33c28 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -2,10 +2,16 @@ name: Python checks (on demand) # Temporarily opt-in during rapid development; never a PR or release gate. on: workflow_dispatch: + inputs: + core_wheels: + description: Qualify published Core wheels and real runtime on both Linux architectures + type: boolean + default: false permissions: contents: read jobs: python: + if: ${{ !inputs.core_wheels }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -17,3 +23,8 @@ jobs: - run: uv sync --locked --group dev --group artifacts - name: Run the complete Python suite run: uv run --no-sync pytest -q --tb=short + core-wheels: + if: ${{ inputs.core_wheels }} + uses: ./.github/workflows/core-wheels.yml + secrets: + LOOP_LIVE_CI_READ_KEY: ${{ secrets.LOOP_LIVE_CI_READ_KEY }} diff --git a/amplifier_web/runtime_deps/pyproject.toml b/amplifier_web/runtime_deps/pyproject.toml index ba6fb8720..5368a8ce2 100644 --- a/amplifier_web/runtime_deps/pyproject.toml +++ b/amplifier_web/runtime_deps/pyproject.toml @@ -5,7 +5,7 @@ description = "Standalone Foundation and loop-live runtime for Amplifier Unified requires-python = ">=3.13" dependencies = [ "amplifier-memory @ git+https://github.com/microsoft/amplifier-bundle-memory@main", - "amplifier-core>=1.6.1", + "amplifier-core>=2.0.1", "amplifier-module-loop-live", "amplifier-foundation", "amplifier-module-loop-streaming", @@ -29,13 +29,18 @@ dependencies = [ ] [tool.uv] +# Core publishes native wheels; never compile it while installing this app. +no-build-package = ["amplifier-core"] # Follow the same Foundation branch while bundles resolve their modules. override-dependencies = [ "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main", ] +[[tool.uv.index]] +url = "https://pypi.org/simple" +default = true + [tool.uv.sources] -amplifier-core = { git = "https://github.com/microsoft/amplifier-core", rev = "main" } amplifier-module-loop-live = { git = "https://github.com/microsoft/amplifier-module-loop-live", rev = "main" } amplifier-foundation = { git = "https://github.com/microsoft/amplifier-foundation", rev = "main" } amplifier-module-loop-streaming = { git = "https://github.com/microsoft/amplifier-module-loop-streaming", rev = "main" } diff --git a/amplifier_web/runtime_environment.py b/amplifier_web/runtime_environment.py index f039ab7f8..ba246fc68 100644 --- a/amplifier_web/runtime_environment.py +++ b/amplifier_web/runtime_environment.py @@ -150,15 +150,36 @@ def inventory(home, *, installed=None): for name, source in sorted(observed.items()): ref = source.get('ref', '') override = source.get('override', False) - eligible = bool(not override and ref and not pinned(ref) and source.get('current')) + registry_migration = core_registry_migration(name, source, baseline) + eligible = bool(not override and not registry_migration and ref and not pinned(ref) and source.get('current')) rows.append({'id': 'runtime:' + name, 'package': name, 'kind': 'runtime dependency', 'label': safe_label(source['url']) if source.get('url') else name, - **source, 'ref': ref, 'status': 'not_checked' if eligible else 'local' if override else 'pinned', + **source, 'ref': ref, 'status': 'not_checked' if eligible or registry_migration else 'local' if override else 'pinned', + **({'registryMigration': True} if registry_migration else {}), 'eligible': eligible, 'usage': 'configured', 'usageEvidence': ['Conversation worker environment', source['provenance']]}) return rows +def core_registry_migration(name, source, baseline): + """Replace only the old app-owned Core default in the next generation. + + An installed fork, fixed ref, editable source or dirty cache remains protected. + The previous base manifest is policy evidence; frozen receipts stay untouched. + """ + if name != 'amplifier-core': + return False + current = tomllib.loads(manifest_path().read_text()).get('tool', {}).get('uv', {}) + previous = tomllib.loads(baseline.decode()).get('tool', {}).get('uv', {}).get('sources', {}).get(name, {}) + upstream = 'https://github.com/microsoft/amplifier-core' + return (name in current.get('no-build-package', []) + and name not in current.get('sources', {}) + and previous.get('git') == upstream and (previous.get('rev') or previous.get('branch')) == 'main' + and not previous.get('subdirectory') and not previous.get('tag') + and source.get('url') == upstream and source.get('ref') == 'main' + and not source.get('subdirectory') and not source.get('override') and not source.get('cacheManaged')) + + class ProtectedRuntimeSource(ValueError): """A fixed reason and package name, never source paths or exception text.""" def __init__(self, package, reason='protected-runtime-source'): @@ -255,6 +276,8 @@ def augmented_manifest(content, rows): name = row['package'] if row.get('override') and (name in declared or row.get('cacheManaged') or row.get('trackedSource')): raise ProtectedRuntimeSource(name) + if row.get('registryMigration'): + continue if row.get('override') or not row.get('url') or not row.get('ref'): continue if name in declared: @@ -325,7 +348,7 @@ async def stage(manager, generation, candidates, *, finalize=True): for row in selected: if row.get('kind') == 'runtime dependency': installed = baseline_sources.get(row['package']) - if not installed or any(installed.get(key, '') != row.get(key, '') for key in ('current', 'url', 'ref', 'subdirectory')): + if not installed or installed.get('registryMigration') or any(installed.get(key, '') != row.get(key, '') for key in ('current', 'url', 'ref', 'subdirectory')): raise ValueError('Runtime dependencies changed since checking; check for updates again.') from .updates import pinned declared = tomllib.loads(content.decode()).get('tool', {}).get('uv', {}).get('sources', {}) diff --git a/docs/validation/release-qualification.md b/docs/validation/release-qualification.md index 46a038485..29cc52fd8 100644 --- a/docs/validation/release-qualification.md +++ b/docs/validation/release-qualification.md @@ -129,3 +129,38 @@ qualification lanes. An exact-candidate promotion system that reuses matching PR/merge qualification, shared prebuilt wheels, and merge-policy changes remain separate proposals. + +## Published Core binaries + +The app host, worker project and Work acceptance project require +`amplifier-core>=2.0.1` from PyPI. This is a minimum supported release, not a +standing exact-version pin. Core's Git source override is removed. The uv +`no-build-package` policy forbids compiling Core; other source dependencies can +still build. The worker uses the same explicit public index as the host so an +outdated local mirror cannot silently keep it on an older Core release. + +An existing worker's old app-owned Core Git-main default migrates only in a new +qualified environment generation. Its previous base manifest must prove that +default, and the installed source must match it. Forks, fixed refs and edited or +local sources are preserved; they are never treated as that default. Existing +locks and rollback receipts are unchanged. Installed-source augmentation must +not reintroduce the retired app-owned Core Git override. + +The release's fresh source resolution, exact cache key and frozen runtime graph +checks remain intact. Rust/Maturin identity and build constraints remain because +other moving-source packages may require native builds. A Core policy/lock +change changes the cache key; no old Git-Core cache can make the registry +selection sticky. Core wheel metadata and its native extension hash now join +the runtime receipt, and qualification fails if amplifier-app-cli is installed. +No CLI companion is needed by Unified's runtime qualification. + +To independently qualify a dependency change on both Linux architectures, run +the existing **Python checks (on demand)** workflow on the reviewed branch with +`core_wheels=true`. Its default remains the full Python suite. The Core mode +runs focused migration/receipt tests and all 24 real runtime checks unchanged +on Linux x86_64 and aarch64, using fresh moving-source resolution and published +Core wheels. It records the tested app revision, full lock, resolved source +identities, installed graph, wheel tags and native binary hashes. It has read-only +repository permissions, no publisher, and no production app access. The private +loop-live key remains confined to explicitly dispatched qualification; automatic +PR jobs do not receive it. macOS and Windows require separate platform evidence. diff --git a/pyproject.toml b/pyproject.toml index 9b1bd1890..8b547e16c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.13" # python-pam imports six but does not declare it in its distribution metadata. dependencies = [ "amplifier-memory @ git+https://github.com/microsoft/amplifier-bundle-memory@main", - "amplifier-core>=1.6.1", + "amplifier-core>=2.0.1", "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main", "aiohttp>=3.12,<4", "packaging>=24", @@ -55,6 +55,10 @@ exclude = ["/.ci/**"] "examples/shell-reader" = "amplifier_web/bundle_data/examples/shell-reader" "examples/shell-controls" = "amplifier_web/bundle_data/examples/shell-controls" +[tool.uv] +# Core publishes native wheels; never compile it while installing this app. +no-build-package = ["amplifier-core"] + [[tool.uv.index]] url = "https://pypi.org/simple" default = true diff --git a/scripts/release_qualification.py b/scripts/release_qualification.py index a7e545fa1..491f91af2 100644 --- a/scripts/release_qualification.py +++ b/scripts/release_qualification.py @@ -108,12 +108,32 @@ def graph(python): return json.loads(run(str(python), '-I', '-c', probe)) +def core_wheel(python): + """Record the installed native binary without importing any CLI or app.""" + probe = '''import hashlib,importlib.metadata as m,importlib.machinery as machinery,json,platform,sys +d=m.distribution('amplifier-core') +native=[p for p in d.files or [] if str(p).startswith('amplifier_core/_engine.') and any(str(p).endswith(s) for s in machinery.EXTENSION_SUFFIXES)] +print(json.dumps({'version':d.version,'direct':json.loads(d.read_text('direct_url.json') or 'null'), + 'wheel':d.read_text('WHEEL'),'native':[{'name':str(p),'sha256':hashlib.sha256(d.locate_file(p).read_bytes()).hexdigest()} for p in native], + 'python':sys.version,'machine':platform.machine(), + 'cliInstalled':any(x.metadata['Name'].lower().replace('_','-')=='amplifier-app-cli' for x in m.distributions())}))''' + value = json.loads(run(str(python), '-I', '-c', probe)) + if (value['direct'] is not None or not value['wheel'] or 'Root-Is-Purelib: false' not in value['wheel'] + or len(value['native']) != 1 or value['cliInstalled']): + raise ValueError('Core must be a registry native wheel without amplifier-app-cli') + return value + + def runtime_snapshot(root, runtime): build = json.loads((runtime / 'build-identity.json').read_text()) if build != build_identity(root, runtime): raise ValueError('Runtime source or toolchain changed after cache selection') - return {'build': digest(build), 'recipes': checkout_identity(root / '.ci/recipes'), - 'graph': graph(runtime / '.venv/bin/python')} + value = {'build': digest(build), 'recipes': checkout_identity(root / '.ci/recipes'), + 'graph': graph(runtime / '.venv/bin/python')} + policy = tomllib.loads((runtime / 'pyproject.toml').read_text()).get('tool', {}).get('uv', {}) + if 'amplifier-core' in policy.get('no-build-package', []): + value['coreWheel'] = core_wheel(runtime / '.venv/bin/python') + return value def receipt(root, evidence, expected, lane, destination, runtime=None, dist=None): @@ -152,7 +172,7 @@ def verify_receipts(root, evidence, expected, receipts, dist): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('command', choices=('candidate', 'verify-candidate', 'runtime-project', 'cache-key', 'runtime-snapshot', 'receipt', 'verify-receipts')) + parser.add_argument('command', choices=('candidate', 'verify-candidate', 'runtime-project', 'cache-key', 'runtime-snapshot', 'core-wheel', 'receipt', 'verify-receipts')) parser.add_argument('--evidence', type=Path) parser.add_argument('--candidate') parser.add_argument('--runtime', type=Path) @@ -161,6 +181,7 @@ def main(): parser.add_argument('--receipts', type=Path) parser.add_argument('--dist', type=Path, default=Path('dist')) parser.add_argument('--output', type=Path) + parser.add_argument('--python', type=Path, default=Path(sys.executable)) args = parser.parse_args() root = Path.cwd() output = None @@ -176,6 +197,8 @@ def main(): output = ('key', 'release-runtime-v1-' + digest(value)) elif args.command == 'runtime-snapshot': write(args.runtime / 'qualified-runtime.json', runtime_snapshot(root, args.runtime)) + elif args.command == 'core-wheel': + write(args.destination, core_wheel(args.python)) elif args.command == 'receipt': receipt(root, args.evidence, args.candidate, args.lane, args.destination, args.runtime, args.dist) else: diff --git a/scripts/work_profile/pyproject.toml b/scripts/work_profile/pyproject.toml index 97f1276da..9f98e322f 100644 --- a/scripts/work_profile/pyproject.toml +++ b/scripts/work_profile/pyproject.toml @@ -3,7 +3,7 @@ name = "amplifier-work-acceptance" version = "0.1.0" requires-python = ">=3.13" dependencies = [ - "amplifier-core>=1.6.1", + "amplifier-core>=2.0.1", "amplifier-unified", "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main", "amplifier-module-loop-live @ git+https://github.com/microsoft/amplifier-module-loop-live@main", @@ -18,6 +18,8 @@ dev = ["pytest>=8", "pytest-asyncio>=0.24"] browser = ["playwright>=1.55,<2"] [tool.uv] +# Core publishes native wheels; never compile it while installing this app. +no-build-package = ["amplifier-core"] package = false [[tool.uv.index]] diff --git a/scripts/work_profile/uv.lock b/scripts/work_profile/uv.lock index 2325bc34d..24248aad6 100644 --- a/scripts/work_profile/uv.lock +++ b/scripts/work_profile/uv.lock @@ -123,7 +123,7 @@ client = [ [[package]] name = "amplifier-core" -version = "1.6.1" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -133,12 +133,12 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, - { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, - { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/b9/53/90edfa9ff6b213f0be37d1cd2ee49df7b644b7d19ac7c8d0331f1ff43775/amplifier_core-2.0.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:445f1e009d2411d55d6dac2ad88484844e688b2cb2901501096faae8a2ac8fdd", size = 8330603, upload-time = "2026-09-23T19:04:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/f865096994e1777274ffc13895efc0c70fb085f96f017083cf2bb6a17562/amplifier_core-2.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9aa0664c2b349e0d90cf20cb2e5e663c37cd54b04fd0cb9776f57ecf31f6ea4", size = 7424176, upload-time = "2026-09-23T19:04:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/fd2653b45a6aba6667ad13113713b7fabfd93200a7a655ecc81dcb7ed162/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2ac4f49bf2858d83bc6ab541ca3fc8042dd17807d25b97f598a1f06b7c305e7", size = 7775409, upload-time = "2026-09-23T19:04:51.956Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/a0360024275c1e214a5d65370ffdb87b35986bd6a1681e1e1c62e34197ac/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a8e7183260b738c32ca16b64f5aacc3e71de2c51918702eae5424ab4f07906", size = 8793506, upload-time = "2026-09-23T19:04:54.038Z" }, + { url = "https://files.pythonhosted.org/packages/42/c0/dbdd12f77d8ded4d76d0170606056d2f493d1d86d08db3b89b579d79904a/amplifier_core-2.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:7d3ea206b6d3f9537115a2afc8b99f5d9e34e9f3236fdc48d8dd6d2d8a631184", size = 9085723, upload-time = "2026-09-23T19:04:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/3042735327bc6fb6216b92632e1d096b6a312c2c0e8f6d111f1b259a56f7/amplifier_core-2.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:7834782f88b764ffb6374b734b74f2e55bc9e57e99197a1ed3d1ab8ae6562e89", size = 7798444, upload-time = "2026-09-23T19:04:58.493Z" }, ] [[package]] @@ -209,7 +209,7 @@ source = { git = "https://github.com/microsoft/amplifier-bundle-context-managed? [[package]] name = "amplifier-unified" -version = "0.20.8" +version = "0.20.14" source = { directory = "../../" } dependencies = [ { name = "aiohttp" }, @@ -235,7 +235,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.12,<4" }, { name = "amplifier-app-tui", marker = "extra == 'tui'", git = "https://github.com/microsoft/amplifier-app-tui?rev=main" }, { name = "amplifier-bundle-context-intelligence", extras = ["client"], git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, - { name = "amplifier-core", specifier = ">=1.6.1" }, + { name = "amplifier-core", specifier = ">=2.0.1" }, { name = "amplifier-foundation", git = "https://github.com/microsoft/amplifier-foundation?rev=main" }, { name = "amplifier-memory", git = "https://github.com/microsoft/amplifier-bundle-memory?rev=main" }, { name = "amplifier-module-tool-computer-use", marker = "extra == 'native-desktop'", git = "https://github.com/microsoft/amplifier-bundle-computer-use?subdirectory=modules%2Ftool-computer-use&rev=main" }, @@ -294,7 +294,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-core", specifier = ">=1.6.1" }, + { name = "amplifier-core", specifier = ">=2.0.1" }, { name = "amplifier-foundation", git = "https://github.com/microsoft/amplifier-foundation?rev=main" }, { name = "amplifier-module-context-managed", git = "https://github.com/microsoft/amplifier-bundle-context-managed?subdirectory=modules%2Fcontext-managed&rev=main" }, { name = "amplifier-module-loop-live", git = "https://github.com/microsoft/amplifier-module-loop-live?rev=main" }, diff --git a/tests/test_core_binary.py b/tests/test_core_binary.py new file mode 100644 index 000000000..33db7c922 --- /dev/null +++ b/tests/test_core_binary.py @@ -0,0 +1,122 @@ +"""Core uses published native wheels while worker generations remain reversible.""" +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tomllib + +import pytest + +from amplifier_web import runtime_environment as environments + +ROOT = Path(__file__).parents[1] +UPSTREAM = 'https://github.com/microsoft/amplifier-core' + + +def test_all_app_dependency_projects_use_released_core_without_a_standing_pin(): + for relative in ['pyproject.toml', 'amplifier_web/runtime_deps/pyproject.toml', 'scripts/work_profile/pyproject.toml']: + project = tomllib.loads((ROOT / relative).read_text()) + assert 'amplifier-core>=2.0.1' in project['project']['dependencies'] + policy = project['tool']['uv'] + assert 'amplifier-core' not in policy.get('sources', {}) + assert policy['no-build-package'] == ['amplifier-core'] + assert any(index['url'] == 'https://pypi.org/simple' and index.get('default') for index in policy['index']) + + +@pytest.mark.parametrize('installer', ['pip', 'sync']) +def test_core_source_build_is_rejected_before_executing_its_backend(tmp_path, installer): + uv = shutil.which('uv') + if not uv: + pytest.skip('uv is needed to verify the wheel-only installation policy') + source = tmp_path / 'core' + source.mkdir() + (source / 'pyproject.toml').write_text('[project]\nname="amplifier-core"\nversion="999.0.0"\n' + '[build-system]\nrequires=[]\nbuild-backend="fixture"\nbackend-path=["."]\n') + marker = tmp_path / 'build-attempted' + (source / 'fixture.py').write_text('from pathlib import Path\nPath(' + repr(str(marker)) + ').touch()\n' + 'raise RuntimeError("Core source builds must not run")\n') + if installer == 'pip': + environment = tmp_path / 'venv' + subprocess.run([uv, 'venv', str(environment), '--python', sys.executable], check=True, capture_output=True) + command = [uv, 'pip', 'install', '--python', str(environment / 'bin/python'), '--no-index', str(source)] + else: + project = tmp_path / 'worker' + project.mkdir() + policy = tomllib.loads((ROOT / 'amplifier_web/runtime_deps/pyproject.toml').read_text())['tool']['uv']['no-build-package'] + (project / 'pyproject.toml').write_text('[project]\nname="worker"\nversion="1"\n' + 'dependencies=["amplifier-core"]\n[tool.uv]\nno-build-package=' + json.dumps(policy) + '\n' + '[tool.uv.sources]\namplifier-core={path=' + json.dumps(str(source)) + '}\n') + command = [uv, 'sync', '--project', str(project), '--python', sys.executable, '--no-index'] + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + assert result.returncode != 0 + assert ('Building source distributions is disabled' in result.stderr + or 'marked as `--no-build` but has no binary distribution' in result.stderr) + assert not marker.exists() + + +@pytest.fixture +def migration(tmp_path, monkeypatch): + shipped = (ROOT / 'amplifier_web/runtime_deps/pyproject.toml').read_bytes() + manifest = tmp_path / 'shipped.toml' + manifest.write_bytes(shipped) + monkeypatch.setattr(environments, 'manifest_path', lambda: manifest) + home = tmp_path / 'app' + generation = 'a' * 32 + receipt = environments.receipt_directory(home, generation) + receipt.mkdir(parents=True) + previous = (b'[project]\nname="old-runtime"\nversion="0.1.1"\ndependencies=["amplifier-core>=1.6.1"]\n' + b'[tool.uv.sources]\namplifier-core={git="' + UPSTREAM.encode() + b'",rev="main"}\n') + for name in ['runtime.toml', 'runtime-base.toml']: + (receipt / name).write_bytes(previous) + (receipt / 'runtime.lock').write_text('version=1\n[[package]]\nname="amplifier-core"\nversion="2.0.0"\n' + 'source={git="' + UPSTREAM + '?rev=main#' + 'b' * 40 + '"}\n') + (home / 'updates/active.json').write_text(json.dumps({'current': generation})) + before = {p.name: p.read_bytes() for p in receipt.iterdir()} + return home, receipt, before, shipped + + +def test_previous_app_core_git_default_moves_to_registry_without_touching_receipts(migration): + home, receipt, before, shipped = migration + rows = environments.inventory(home) + assert next(r for r in rows if r['kind'] == 'runtime environment')['status'] == 'update' + core = next(r for r in rows if r.get('package') == 'amplifier-core') + assert core['registryMigration'] and not core['eligible'] + assert core['current'] == 'b' * 40 and core['url'] == UPSTREAM + assert environments.augmented_manifest(shipped, rows) == shipped + assert {p.name: p.read_bytes() for p in receipt.iterdir()} == before + + +@pytest.mark.parametrize('change', ['fork', 'fixed-ref', 'subdirectory', 'editable', 'dirty-cache']) +def test_explicit_installed_core_overrides_are_never_migrated(migration, change): + home, receipt, before, shipped = migration + source = {'url': UPSTREAM, 'ref': 'main', 'current': 'b' * 40, 'provenance': 'installed Git distribution'} + if change == 'fork': + source['url'] = 'https://example.invalid/user/core' + elif change == 'fixed-ref': + source['ref'] = 'c' * 40 + elif change == 'subdirectory': + source['subdirectory'] = 'local-core' + else: + source = {'override': True, 'current': '2.0.0', 'provenance': 'installed local or registry override', + **({'cacheManaged': True} if change == 'dirty-cache' else {})} + rows = environments.inventory(home, installed={'amplifier-core': source}) + core = next(r for r in rows if r.get('package') == 'amplifier-core') + assert 'registryMigration' not in core + if change in {'editable', 'dirty-cache'}: + # A local installation cannot conceal the old tracked Git dependency. + core['trackedSource'] = True + with pytest.raises(environments.ProtectedRuntimeSource): + environments.augmented_manifest(shipped, rows) + else: + generated = tomllib.loads(environments.augmented_manifest(shipped, rows).decode()) + assert any(source['url'] in dep and source['ref'] in dep + for dep in generated['dependency-groups']['unified-managed-runtime']) + assert {p.name: p.read_bytes() for p in receipt.iterdir()} == before + + +def test_same_core_source_is_not_migrated_without_previous_app_policy(migration): + home, receipt, _, _ = migration + (receipt / 'runtime-base.toml').write_text('[project]\nname="custom"\n') + core = next(r for r in environments.inventory(home) if r.get('package') == 'amplifier-core') + assert 'registryMigration' not in core and core['eligible'] diff --git a/tests/test_release_qualification.py b/tests/test_release_qualification.py index ab2e989ed..d888059cf 100644 --- a/tests/test_release_qualification.py +++ b/tests/test_release_qualification.py @@ -100,9 +100,13 @@ def test_promotion_rejects_mixed_or_tampered_evidence(qualified, change): qualification.verify_receipts(root, evidence, expected, receipts, dist) -def test_runtime_changes_after_tests_cannot_produce_a_receipt(qualified): +@pytest.mark.parametrize('change', ['graph', 'native-binary']) +def test_runtime_changes_after_tests_cannot_produce_a_receipt(qualified, change): root, evidence, expected, receipts, dist, runtime, snapshot = qualified - snapshot['graph'] = [{'name': 'core', 'version': 'new-untested-version'}] + if change == 'graph': + snapshot['graph'] = [{'name': 'core', 'version': 'new-untested-version'}] + else: + snapshot['coreWheel'] = {'native': [{'sha256': 'changed-binary'}]} with pytest.raises(ValueError, match='changed during qualification'): qualification.receipt(root, evidence, expected, 'runtime', receipts / 'runtime.json', runtime, dist) @@ -154,6 +158,28 @@ def test_cache_identity_does_not_include_credentials(runtime_build, monkeypatch) assert 'never-export' not in json.dumps(with_flags) +@pytest.mark.parametrize('change', ['none', 'source-install', 'missing-wheel', 'pure-python', 'missing-native', 'cli']) +def test_core_wheel_receipt_rejects_source_or_cli_installations(monkeypatch, change): + value = {'version': '2.0.1', 'direct': None, 'wheel': 'Root-Is-Purelib: false\nTag: cp311-abi3-manylinux_2_17_x86_64', + 'native': [{'name': 'amplifier_core/_engine.abi3.so', 'sha256': 'a' * 64}], 'cliInstalled': False} + if change == 'source-install': + value['direct'] = {'url': 'https://github.com/microsoft/amplifier-core', 'vcs_info': {'vcs': 'git'}} + elif change == 'missing-wheel': + value['wheel'] = None + elif change == 'pure-python': + value['wheel'] = 'Root-Is-Purelib: true' + elif change == 'missing-native': + value['native'] = [] + elif change == 'cli': + value['cliInstalled'] = True + monkeypatch.setattr(qualification, 'run', lambda *args: json.dumps(value)) + if change == 'none': + assert qualification.core_wheel(Path('python')) == value + else: + with pytest.raises(ValueError, match='registry native wheel'): + qualification.core_wheel(Path('python')) + + def test_runtime_project_constrains_the_recorded_build_backend(tmp_path, monkeypatch): source = tmp_path / 'amplifier_web/runtime_deps' source.mkdir(parents=True) @@ -240,3 +266,21 @@ def test_runtime_cache_is_selected_after_fresh_resolution_without_fallback(): save = next(i for i, step in enumerate(steps) if step.get('uses') == 'actions/cache/save@v4') assert install < save assert 'uv cache clean amplifier-module-loop-live' in steps[install]['run'] + + +def test_native_core_qualification_is_manual_and_cannot_publish(): + workflows = ROOT / '.github/workflows' + entry = yaml.load((workflows / 'python-checks.yml').read_text(), Loader=yaml.BaseLoader) + native = yaml.load((workflows / 'core-wheels.yml').read_text(), Loader=yaml.BaseLoader) + assert set(entry['on']) == {'workflow_dispatch'} + assert entry['on']['workflow_dispatch']['inputs']['core_wheels']['default'] == 'false' + assert entry['jobs']['core-wheels']['uses'] == './.github/workflows/core-wheels.yml' + assert set(native['on']) == {'workflow_call'} + assert native['permissions'] == {'contents': 'read'} + job = native['jobs']['runtime'] + assert job['if'] == "github.event_name == 'workflow_dispatch'" + assert set(job['strategy']['matrix']['os']) == {'ubuntu-latest', 'ubuntu-24.04-arm'} + commands = '\n'.join(step.get('run', '') for step in job['steps']) + assert 'tests/test_runtime_module_cache.py' in commands + assert 'runtime-snapshot' in commands and 'cmp ' in commands + assert 'publish' not in commands diff --git a/uv.lock b/uv.lock index 0b3fc9c56..de4b47be0 100644 --- a/uv.lock +++ b/uv.lock @@ -131,7 +131,7 @@ client = [ [[package]] name = "amplifier-core" -version = "1.6.1" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -141,12 +141,12 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, - { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, - { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/b9/53/90edfa9ff6b213f0be37d1cd2ee49df7b644b7d19ac7c8d0331f1ff43775/amplifier_core-2.0.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:445f1e009d2411d55d6dac2ad88484844e688b2cb2901501096faae8a2ac8fdd", size = 8330603, upload-time = "2026-09-23T19:04:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/f865096994e1777274ffc13895efc0c70fb085f96f017083cf2bb6a17562/amplifier_core-2.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9aa0664c2b349e0d90cf20cb2e5e663c37cd54b04fd0cb9776f57ecf31f6ea4", size = 7424176, upload-time = "2026-09-23T19:04:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/fd2653b45a6aba6667ad13113713b7fabfd93200a7a655ecc81dcb7ed162/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2ac4f49bf2858d83bc6ab541ca3fc8042dd17807d25b97f598a1f06b7c305e7", size = 7775409, upload-time = "2026-09-23T19:04:51.956Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/a0360024275c1e214a5d65370ffdb87b35986bd6a1681e1e1c62e34197ac/amplifier_core-2.0.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a8e7183260b738c32ca16b64f5aacc3e71de2c51918702eae5424ab4f07906", size = 8793506, upload-time = "2026-09-23T19:04:54.038Z" }, + { url = "https://files.pythonhosted.org/packages/42/c0/dbdd12f77d8ded4d76d0170606056d2f493d1d86d08db3b89b579d79904a/amplifier_core-2.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:7d3ea206b6d3f9537115a2afc8b99f5d9e34e9f3236fdc48d8dd6d2d8a631184", size = 9085723, upload-time = "2026-09-23T19:04:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/3042735327bc6fb6216b92632e1d096b6a312c2c0e8f6d111f1b259a56f7/amplifier_core-2.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:7834782f88b764ffb6374b734b74f2e55bc9e57e99197a1ed3d1ab8ae6562e89", size = 7798444, upload-time = "2026-09-23T19:04:58.493Z" }, ] [[package]] @@ -230,7 +230,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.12,<4" }, { name = "amplifier-app-tui", marker = "extra == 'tui'", git = "https://github.com/microsoft/amplifier-app-tui?rev=main" }, { name = "amplifier-bundle-context-intelligence", extras = ["client"], git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, - { name = "amplifier-core", specifier = ">=1.6.1" }, + { name = "amplifier-core", specifier = ">=2.0.1" }, { name = "amplifier-foundation", git = "https://github.com/microsoft/amplifier-foundation?rev=main" }, { name = "amplifier-memory", git = "https://github.com/microsoft/amplifier-bundle-memory?rev=main" }, { name = "amplifier-module-tool-computer-use", marker = "extra == 'native-desktop'", git = "https://github.com/microsoft/amplifier-bundle-computer-use?subdirectory=modules%2Ftool-computer-use&rev=main" }, From adc8a5ba8f3860f476cc71c2624555084cc12930 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 23 Sep 2026 14:12:37 -0700 Subject: [PATCH 2/2] Allow wrapped uv diagnostics in wheel-only policy tests --- tests/test_core_binary.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_core_binary.py b/tests/test_core_binary.py index 33db7c922..a4d58fba6 100644 --- a/tests/test_core_binary.py +++ b/tests/test_core_binary.py @@ -50,8 +50,9 @@ def test_core_source_build_is_rejected_before_executing_its_backend(tmp_path, in command = [uv, 'sync', '--project', str(project), '--python', sys.executable, '--no-index'] result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) assert result.returncode != 0 - assert ('Building source distributions is disabled' in result.stderr - or 'marked as `--no-build` but has no binary distribution' in result.stderr) + diagnostic = ' '.join(result.stderr.split()) # uv wraps diagnostics to the terminal width. + assert ('Building source distributions is disabled' in diagnostic + or 'marked as `--no-build` but has no binary distribution' in diagnostic) assert not marker.exists()