From 82fd91e0174f9650d949e822593930c88fbedc66 Mon Sep 17 00:00:00 2001 From: Weilue Luo Date: Wed, 26 Aug 2026 02:00:02 +0100 Subject: [PATCH 1/2] release: gate every extension on its own component round Extension defects only surfaced at the platform level because nothing in this repository ever instantiated a built component: the package tests exercise plain JS modules, so an opaque Wasm trap, a forwarded null sampling field, a dropped sealed instruction and a `list` the guest does not recognise were all reachable only from a tagged deployment. Each package now drives its actual `.component.wasm` through a realistic round on Brain's own `component-host` worker, at the immutable Brain revision the AWS MicroVM runtime already pins, and asserts that a failing export reaches the kernel as a typed `extension-error` carrying its reason. Staging fans out per package in an order derived from the release itself, each staged version is smoked from the registry rather than rebuilt, and promotion moves each package on its own receipt for that exact archive. --- .github/actions/component-host/action.yml | 41 ++ .github/workflows/ci.yml | 64 ++ .github/workflows/npm-publish.yml | 134 +++- package.json | 2 +- tools/component-smoke.mjs | 654 ++++++++++++++++++++ tools/component-smoke/agentloop.fixture.mjs | 22 + tools/component-smoke/model.fixture.mjs | 69 +++ tools/npm-release.mjs | 42 +- tools/npm-release.test.mjs | 21 +- tools/publish.mjs | 60 +- tools/publish.test.mjs | 121 ++++ tools/verify-dependencies.test.mjs | 7 + 12 files changed, 1216 insertions(+), 21 deletions(-) create mode 100644 .github/actions/component-host/action.yml create mode 100644 tools/component-smoke.mjs create mode 100644 tools/component-smoke/agentloop.fixture.mjs create mode 100644 tools/component-smoke/model.fixture.mjs create mode 100644 tools/publish.test.mjs diff --git a/.github/actions/component-host/action.yml b/.github/actions/component-host/action.yml new file mode 100644 index 0000000..8cb0f60 --- /dev/null +++ b/.github/actions/component-host/action.yml @@ -0,0 +1,41 @@ +name: component host +description: Brain's own component-host worker, built from the immutable Brain revision this repository pins. + +outputs: + binary: + description: The component-host executable. + value: ${{ steps.install.outputs.binary }} + +runs: + using: composite + steps: + - name: Read the pinned Brain revision + id: source + shell: bash + run: | + set -euo pipefail + # The AWS MicroVM runtime already pins the one immutable Brain source this repository + # builds against; extension rounds must cross that exact host ABI, not a second pin. + revision=$(grep -m1 -oE 'brain\.git", rev = "[0-9a-f]{40}"' \ + packages/env-aws-microvm/runtime/Cargo.toml | grep -oE '[0-9a-f]{40}') + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + with: + path: ~/.component-host + key: component-host-${{ runner.os }}-${{ steps.source.outputs.revision }} + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - name: Install the worker + id: install + shell: bash + env: + REVISION: ${{ steps.source.outputs.revision }} + run: | + set -euo pipefail + binary="$HOME/.component-host/bin/component-host" + test -x "$binary" || cargo install --locked --root "$HOME/.component-host" \ + --git https://github.com/aexhq/brain.git --rev "$REVISION" \ + --bin component-host brain-component-host + echo "binary=$binary" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771331b..07b8f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,70 @@ jobs: done - run: npm test + components: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + workspaces: ${{ steps.list.outputs.workspaces }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run build + - id: list + run: echo "workspaces=$(node tools/npm-release.mjs workspaces)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v6 + with: + name: extensions-components-${{ github.sha }} + path: packages/*/dist + if-no-files-found: error + retention-days: 1 + + component-host: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/component-host + + # The four component contracts only exist between a real componentized guest and a real host, so + # a plain-JS test cannot reach an opaque trap, a forwarded null, a dropped sealed instruction or + # a `list` the guest does not recognise. Each package proves its own components here. + component-smoke: + needs: [components, component-host] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.components.outputs.workspaces) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - uses: ./.github/actions/component-host + id: host + - run: npm ci + - uses: actions/download-artifact@v7 + with: + name: extensions-components-${{ github.sha }} + path: packages + - name: Drive the built components through a real round + env: + COMPONENT_HOST: ${{ steps.host.outputs.binary }} + run: node tools/component-smoke.mjs "${{ matrix.workspace }}" + runtime-format: runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 638cc6b..abb0949 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -114,6 +114,19 @@ jobs: - run: npm run build - run: npm run package-smoke + component-host: + if: inputs.operation == 'bootstrap' || inputs.operation == 'stage' + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/component-host + prepare: if: inputs.operation == 'bootstrap' || inputs.operation == 'stage' needs: [validate, stage-tests, stage-package-smoke] @@ -123,6 +136,9 @@ jobs: contents: read outputs: versions: ${{ steps.pack.outputs.versions }} + workspaces: ${{ steps.pack.outputs.workspaces }} + base: ${{ steps.pack.outputs.base }} + dependents: ${{ steps.pack.outputs.dependents }} steps: - uses: actions/checkout@v7 with: @@ -144,7 +160,11 @@ jobs: release_dir="$RUNNER_TEMP/extensions-npm-release" node tools/npm-release.mjs pack "$release_dir" versions=$(node tools/npm-release.mjs versions "$release_dir/manifest.json") + order=$(node tools/npm-release.mjs order "$release_dir/manifest.json") echo "versions=$versions" >> "$GITHUB_OUTPUT" + echo "workspaces=$(node tools/npm-release.mjs workspaces)" >> "$GITHUB_OUTPUT" + echo "base=$(jq -c .base <<<"$order")" >> "$GITHUB_OUTPUT" + echo "dependents=$(jq -c .dependents <<<"$order")" >> "$GITHUB_OUTPUT" node tools/npm-release.mjs markdown "$release_dir/manifest.json" >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v6 with: @@ -161,6 +181,41 @@ jobs: permissions: contents: read id-token: write + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.base || '[]') }} + steps: + - uses: actions/download-artifact@v7 + with: + name: extensions-npm-release-${{ github.sha }} + path: ${{ runner.temp }}/extensions-npm-release + - uses: actions/setup-node@v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: npm install --global npm@11.19.0 + - run: node "$RUNNER_TEMP/extensions-npm-release/verify-dependencies.mjs" "$RUNNER_TEMP/extensions-npm-release/manifest.json" + - name: Publish once with provenance under next + env: + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage "${{ matrix.workspace }}" + + # Registry visibility of an exact version is a hard dependency of everything built or installed + # against it, so these wait for the wave above and for nothing else. + stage-dependents: + if: inputs.operation == 'stage' + needs: [prepare, stage] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.dependents || '[]') }} steps: - uses: actions/download-artifact@v7 with: @@ -176,11 +231,74 @@ jobs: - name: Publish once with provenance under next env: EXPECTED_COMMIT: ${{ inputs.expected_commit }} - run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage - - name: Record staged versions + run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage "${{ matrix.workspace }}" + + # The staged archive is the artifact a session loads, so its components run against the real host + # ABI here, installed from the registry rather than rebuilt. Promotion consumes these receipts. + component-smoke: + if: ${{ !cancelled() && (needs.stage-dependents.result == 'success' || needs.bootstrap.result == 'success') }} + needs: [prepare, component-host, stage-dependents, bootstrap] + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.workspaces || '[]') }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - uses: ./.github/actions/component-host + id: host + - run: npm install --global npm@11.19.0 + - run: npm ci + - uses: actions/download-artifact@v7 + with: + name: extensions-npm-release-${{ github.sha }} + path: ${{ runner.temp }}/extensions-npm-release + - name: Install the exact staged version + id: staged + shell: bash + env: + WORKSPACE: ${{ matrix.workspace }} + run: | + set -euo pipefail + entry=$(jq -e --arg workspace "$WORKSPACE" '.packages[] | select(.workspace == $workspace)' \ + "$RUNNER_TEMP/extensions-npm-release/manifest.json") + spec="$(jq -r .name <<<"$entry")@$(jq -r .version <<<"$entry")" + consumer="$RUNNER_TEMP/staged-consumer" + mkdir -p "$consumer" + cd "$consumer" + npm init --yes > /dev/null + npm install --no-audit --no-fund "$spec" + integrity=$(npm view "$spec" dist.integrity) + test "$integrity" = "$(jq -r .integrity <<<"$entry")" + echo "from=$consumer/node_modules/$(jq -r .name <<<"$entry")" >> "$GITHUB_OUTPUT" + echo "integrity=$integrity" >> "$GITHUB_OUTPUT" + - name: Drive the staged components through a real round env: - VERSIONS: ${{ needs.prepare.outputs.versions }} - run: echo "Staged exact versions under npm dist-tag \`next\` — \`$VERSIONS\`" >> "$GITHUB_STEP_SUMMARY" + COMPONENT_HOST: ${{ steps.host.outputs.binary }} + FROM: ${{ steps.staged.outputs.from }} + SMOKE_INTEGRITY: ${{ steps.staged.outputs.integrity }} + WORKSPACE: ${{ matrix.workspace }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/smoke" + node tools/component-smoke.mjs "$WORKSPACE" --from "$FROM" \ + --receipt "$RUNNER_TEMP/smoke/$WORKSPACE.json" + - uses: actions/upload-artifact@v6 + with: + name: extensions-npm-smoke-${{ github.sha }}-${{ matrix.workspace }} + path: ${{ runner.temp }}/smoke + if-no-files-found: error + retention-days: 30 bootstrap: if: inputs.operation == 'bootstrap' @@ -264,6 +382,13 @@ jobs: path: ${{ runner.temp }}/extensions-npm-release github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ inputs.stage_run_id }} + - uses: actions/download-artifact@v7 + with: + pattern: extensions-npm-smoke-${{ needs.validate.outputs.release_sha }}-* + merge-multiple: true + path: ${{ runner.temp }}/extensions-npm-smoke + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ inputs.stage_run_id }} - uses: actions/setup-node@v7 with: node-version: '24' @@ -275,4 +400,5 @@ jobs: env: EXPECTED_COMMIT: ${{ needs.validate.outputs.release_sha }} NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} + SMOKE_RECEIPTS: ${{ runner.temp }}/extensions-npm-smoke run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" promote diff --git a/package.json b/package.json index 912c376..175b488 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "workspaces": ["packages/*"], "scripts": { "build": "npm run build --workspaces --if-present", - "test": "npm test --workspaces --if-present && node --test tools/npm-release.test.mjs tools/verify-dependencies.test.mjs", + "test": "npm test --workspaces --if-present && node --test tools/npm-release.test.mjs tools/publish.test.mjs tools/verify-dependencies.test.mjs", "package-smoke": "node tools/package-smoke.mjs" }, "devDependencies": { diff --git a/tools/component-smoke.mjs b/tools/component-smoke.mjs new file mode 100644 index 0000000..272a625 --- /dev/null +++ b/tools/component-smoke.mjs @@ -0,0 +1,654 @@ +/** + * Run one package's published components against the real host ABI. + * + * node tools/component-smoke.mjs [--from ] [--receipt ] + * + * `--from` selects the package under test: the working tree by default, or a clean install of the + * exact staged version. The host is Brain's own `component-host` worker (`COMPONENT_HOST`), so + * these rounds cross the same `contracts/*` boundary the kernel crosses — the only place an + * opaque Wasm trap, a null sampling field, a dropped sealed instruction or a `list` the guest + * does not recognise becomes visible before a tagged deployment. + */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(import.meta.dirname, ".."); +const fixtures = path.join(import.meta.dirname, "component-smoke"); +const componentHost = process.env.COMPONENT_HOST; +if (componentHost === undefined) { + throw new Error("COMPONENT_HOST must name Brain's component-host binary"); +} + +class Host { + #child; + #pending = new Map(); + #next = 1; + #capabilities = () => { + throw new Error("the scenario bound no capability handler"); + }; + + constructor(binary) { + this.#child = spawn(binary, [], { stdio: ["pipe", "pipe", "inherit"] }); + this.#child.on("exit", (code, signal) => { + const reason = new Error(`the component host exited with ${signal ?? code}`); + for (const pending of this.#pending.values()) pending.reject(reason); + this.#pending.clear(); + }); + createInterface({ input: this.#child.stdout }).on("line", (line) => { + this.#frame(line).catch((error) => { + this.#child.kill(); + throw error; + }); + }); + } + + bind(capabilities) { + this.#capabilities = capabilities; + } + + close() { + this.#child.stdin.end(); + } + + request(request) { + const id = this.#next; + this.#next += 1; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + this.#write({ frame: "request", id, request }); + }); + } + + #write(frame) { + this.#child.stdin.write(`${JSON.stringify(frame)}\n`); + } + + async #frame(line) { + const frame = JSON.parse(line); + if (frame.frame === "response") { + const pending = this.#pending.get(frame.id); + this.#pending.delete(frame.id); + if ("Ok" in frame.result) pending.resolve(frame.result.Ok); + else pending.reject(new Error(frame.result.Err)); + return; + } + try { + const value = await this.#capabilities(frame.call); + this.#write({ frame: "capability_result", id: frame.id, result: { Ok: value ?? null } }); + } catch (error) { + this.#write({ + frame: "capability_result", + id: frame.id, + result: { + Err: { + code: error.code ?? "smoke_capability_failed", + message: String(error.message ?? error), + retryable: false, + }, + }, + }); + } + } +} + +const denied = (code, message) => Object.assign(new Error(message), { code }); +const utf8 = (bytes) => Buffer.from(bytes).toString("utf8"); +const sse = (frames) => frames.map((frame) => `${frame}\n\n`).join(""); +const DEADLINE = 4_102_444_800_000; + +/** Brain always sends every sampling field; an unset one arrives as an explicit JSON null. */ +const sealedPrefix = (extra) => ({ + system_prompt: "Answer with one word.", + max_tokens: null, + temperature: null, + stop_sequences: null, + tool_choice_none: null, + ...extra, +}); + +const sealedRequest = (body, absent) => { + const value = JSON.parse(body); + for (const field of absent) assert.equal(field in value, false, `${field} reached the provider`); + assert.match(body, /Answer with one word\./u, "the sealed instructions never reached the provider"); +}; + +async function model(host, target, component) { + const started = []; + const stream = target.chunks.map((chunk, index) => ({ cursor: `c${index}`, ...chunk })); + host.bind((call) => { + if (call.capability === "model.http.start") { + started.push(call.request); + return { request_id: "req-smoke" }; + } + if (call.capability === "model.http.read") { + const chunk = stream.shift(); + if (chunk === undefined) throw denied("smoke_stream", "the Model read past the scripted stream"); + return { + cursor: chunk.cursor, + // Only the first chunk carries a status, and a host `list` reaches a componentized + // guest as a plain array of byte values, never a `Uint8Array`. + status: chunk.status ?? null, + headers: chunk.status === undefined ? [] : [["content-type", "text/event-stream"]], + bytes: [...Buffer.from(chunk.body ?? "", "utf8")], + done: chunk.done === true, + }; + } + throw denied("smoke_capability", `unscripted Model capability ${call.capability}`); + }); + + const request = { + operation_id: "model_op_smoke", + model: target.model, + messages_json: JSON.stringify([{ role: "user", content: [{ type: "text", text: "hi" }] }]), + tools_json: "[]", + response_format_json: null, + generation_json: JSON.stringify(target.generation), + provider_options_json: JSON.stringify(target.options), + deadline_at_ms: DEADLINE, + }; + const attempt = await host.request({ + kind: "model_start", + instance_id: "model-smoke", + component, + request, + }); + assert.equal(typeof attempt.provider_operation_id, "string"); + assert.equal(started.length, 1); + target.body(utf8(started[0].body)); + + const texts = []; + let observation; + let cursor = null; + do { + observation = await host.request({ + kind: "model_observe", + instance_id: "model-smoke", + provider_operation_id: attempt.provider_operation_id, + cursor, + }); + cursor = observation.next_cursor; + for (const event of observation.events) { + if (event.kind === "TextDelta") texts.push(JSON.parse(event.payload_json).text); + } + } while (observation.state === "Streaming"); + assert.equal(observation.state, "Completed"); + assert.equal(texts.join(""), target.text); + assert.equal(typeof observation.terminal_json, "string"); + await host.request({ + kind: "model_acknowledge", + instance_id: "model-smoke", + provider_operation_id: attempt.provider_operation_id, + terminal_json: observation.terminal_json, + }); + + stream.push({ cursor: "c-failed", status: 500, body: "" }); + const failing = await host.request({ + kind: "model_start", + instance_id: "model-smoke-failed", + component, + request, + }); + await assert.rejects( + host.request({ + kind: "model_observe", + instance_id: "model-smoke-failed", + provider_operation_id: failing.provider_operation_id, + cursor: null, + }), + // componentize-js compiles a thrown plain `Error` into a Wasm trap whose message is gone; a + // typed `extension-error` is the only way this reason reaches the kernel. + /status 500/u, + "the provider status never reached the kernel", + ); + await host.request({ kind: "release", world: "model", instance_id: "model-smoke-failed" }); +} + +async function environment(host, _target, component) { + const bundle = [...Buffer.from("smoke bundle", "utf8")]; + const observations = [ + { state: "running", cursor: "c1", chunks: [{ seq: 1, text: "working" }] }, + { state: "completed", cursor: "c2", chunks: [], terminal_json: { ok: true, exit_code: 0 } }, + ]; + let failing = false; + host.bind((call) => { + assert.equal(call.capability, "environment.dispatch"); + if (failing) throw denied("driver_unavailable", "the smoke driver refused the operation"); + const { action, request } = call.request; + if (action === "submit") { + assert.equal(utf8(Buffer.from(request.operation.bundle_base64, "base64")), "smoke bundle"); + return { provider_operation_id: "prov-smoke" }; + } + if (action === "observe") return observations.shift(); + return {}; + }); + + const resolve = { + tenant_id: "ten_smoke", + session_id: "ses_smoke", + root_id: "ses_smoke", + parent_id: null, + environment_id: "workspace", + config_json: JSON.stringify({ driver: { kind: "smoke" }, configuration: { region: "local" } }), + policy_json: JSON.stringify({ network: "deny" }), + }; + const operation = { + operation_id: "env_op_smoke", + kind: "invoke", + descriptor_json: JSON.stringify({ runtime: "node22", tool_name: "smoke" }), + bundle, + input_json: JSON.stringify({ command: "true" }), + deadline_at_ms: DEADLINE, + }; + const resolved = await host.request({ + kind: "environment_resolve", + instance_id: "env-smoke", + component, + request: resolve, + }); + const binding = JSON.parse(resolved.binding_json); + assert.equal(binding.session_id, "ses_smoke"); + assert.deepEqual(binding.policy, { network: "deny" }); + + const submitted = await host.request({ + kind: "environment_submit", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + operation, + }); + assert.equal(submitted.provider_operation_id, "prov-smoke"); + + const observe = (cursor) => host.request({ + kind: "environment_observe", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + provider_operation_id: submitted.provider_operation_id, + cursor, + }); + const running = await observe(null); + assert.equal(running.state, "Running"); + assert.deepEqual(JSON.parse(running.chunks_json), [{ seq: 1, text: "working" }]); + const completed = await observe(running.cursor); + assert.equal(completed.state, "Completed"); + assert.deepEqual(JSON.parse(completed.terminal_json), { ok: true, exit_code: 0 }); + + await host.request({ + kind: "environment_acknowledge", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + provider_operation_id: submitted.provider_operation_id, + terminal_json: completed.terminal_json, + }); + await host.request({ + kind: "environment_release", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + }); + + failing = true; + await host.request({ + kind: "environment_resolve", + instance_id: "env-smoke-failed", + component, + request: resolve, + }); + await assert.rejects( + host.request({ + kind: "environment_submit", + instance_id: "env-smoke-failed", + binding_json: resolved.binding_json, + operation, + }), + /the smoke driver refused the operation/u, + "the driver failure never reached the kernel", + ); + await host.request({ kind: "release", world: "environment", instance_id: "env-smoke-failed" }); +} + +async function tool(host, target, component) { + const config = target.configFile === undefined + ? target.config ?? {} + : JSON.parse(await readFile(path.join(target.from, target.configFile), "utf8")); + let failing = false; + host.bind((call) => { + if (failing) throw denied("environment_unavailable", "the smoke grant refused the call"); + if (call.capability === "tool.environment.invoke") { + assert.deepEqual(JSON.parse(call.request.descriptor_json), config.descriptor); + assert.equal(call.request.bundle_base64 === null, config.bundleBase64 === undefined); + return JSON.stringify({ value_json: JSON.stringify({ ok: true }), content: "ok", is_error: false }); + } + if (call.capability === "tool.children.spawn") { + assert.equal(JSON.parse(call.request.request_json).name, "smoke child"); + return JSON.stringify({ child_id: "chi_smoke", status: "running" }); + } + throw denied("smoke_capability", `unscripted Tool capability ${call.capability}`); + }); + + const invocation = { + metadata: { + tenant_id: "ten_smoke", + session_id: "ses_smoke", + turn_id: "turn_smoke", + call_id: "call_smoke", + tool_name: target.toolName, + }, + input_json: JSON.stringify(target.input), + config_json: JSON.stringify(config), + deadline_at_ms: DEADLINE, + }; + const outcome = await host.request({ + kind: "tool", + component, + request: invocation, + grants: [target.grant], + }); + assert.equal(outcome.is_error, false); + assert.deepEqual(JSON.parse(outcome.value_json), target.value); + + failing = true; + await assert.rejects( + host.request({ kind: "tool", component, request: invocation, grants: [target.grant] }), + /the smoke grant refused the call/u, + "the grant failure never reached the kernel", + ); +} + +const SESSION = { + session_id: "ses_smoke", + model: "smoke-model", + limits: { max_rounds_per_turn: 8, turn_wall_ms: 60_000, max_parallel_tools: 4 }, + metadata: { tools: [] }, +}; + +const round = (answers, seen) => (call) => { + assert.equal(call.capability, "agentloop.call"); + seen.push(call.request.op); + const answer = answers[call.request.op.op]; + if (answer === undefined) throw denied("internal", `unscripted Agentloop op ${call.request.op.op}`); + return JSON.stringify(answer(call.request.op)); +}; + +const completedRound = { + model_stream: () => ({ + result: { + op: "model_stream", + message: { + content: [{ type: "text", text: "hello" }], + stop_reason: "end_turn", + model: SESSION.model, + usage: { input_tokens: 3, output_tokens: 1 }, + }, + }, + }), + tools_dispatch: ({ calls }) => ({ + result: { + op: "tools_dispatch", + results: calls.map((item) => ({ + tool_call_id: item.tool_call_id, + name: item.name, + is_error: false, + content: [{ type: "text", text: "ok" }], + })), + }, + }), + journal_append: () => ({ result: { op: "journal_append", first_seq: 5, last_seq: 5 } }), + journal_read: () => ({ result: { op: "journal_read", entries: [] } }), + kv_get: () => ({ result: { op: "kv_get", entries: {} } }), + kv_set: () => ({ result: { op: "kv_set" } }), + turn_finish: () => ({ result: { op: "turn_finish" } }), + turn_fail: () => ({ result: { op: "turn_fail" } }), +}; + +async function agentloop(host, _target, component) { + const activate = (kind, payload) => host.request({ + kind: "agentloop", + instance_id: "loop-smoke", + component, + request: { + operation_id: `act_${kind}`, + session_id: SESSION.session_id, + kind, + payload_json: JSON.stringify(payload), + config_json: JSON.stringify({ instructions: "Answer with one word." }), + deadline_at_ms: DEADLINE, + }, + }); + + let seen = []; + host.bind(round(completedRound, seen)); + const started = await activate("session_start", { + activation_id: "act-start", + session: SESSION, + resumed: false, + kv: {}, + tail: [ + { + type: "user_message", + seq: 1, + at: "2026-08-25T00:00:00Z", + content: [{ type: "text", text: "earlier" }], + }, + ], + }); + assert.equal(JSON.parse(started.payload_json).outcome, "completed"); + + const message = { + activation_id: "act-1", + kind: "message", + session: SESSION, + message: { seq: 4, at: "2026-08-25T00:00:01Z", content: [{ type: "text", text: "say hello" }] }, + }; + const completed = await activate("message", message); + assert.equal(JSON.parse(completed.payload_json).outcome, "completed"); + assert.ok(seen.some((op) => op.op === "model_stream"), "the loop composed no model round"); + assert.match(JSON.stringify(seen), /say hello/u, "the admitted message never reached the loop"); + assert.match(JSON.stringify(seen), /earlier/u, "the session_start tail never reached the loop"); + + seen = []; + host.bind(round({ + ...completedRound, + model_stream: () => ({ + error: { code: "provider_error", message: "the smoke provider refused the round", retryable: false }, + }), + }, seen)); + // A failed round has to reach the kernel as data carrying the provider's reason — through the + // activation payload or a terminal op — never as a trap whose message is gone. + const failed = await activate("message", { ...message, activation_id: "act-2" }); + assert.match( + JSON.stringify([failed.payload_json, seen]), + /the smoke provider refused the round/u, + "the provider reason never reached the kernel", + ); + await host.request({ kind: "release", world: "agentloop", instance_id: "loop-smoke" }); +} + +const scenarios = { model, environment, tool, agentloop }; + +const openai = { + kind: "model", + component: "dist/model.component.wasm", + model: "gpt-smoke", + options: { baseUrl: "https://provider.invalid", apiKey: "sk-smoke", outputTokenParameter: "max_completion_tokens" }, + generation: sealedPrefix({ reasoning_effort: null }), + body: (body) => sealedRequest(body, ["temperature", "max_completion_tokens", "reasoning_effort", "stop", "tool_choice"]), + chunks: [ + { status: 200, body: sse([`data: ${JSON.stringify({ choices: [{ delta: { content: "hel" } }] })}`]) }, + { + body: sse([ + `data: ${JSON.stringify({ choices: [{ delta: { content: "lo" }, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`, + "data: [DONE]", + ]), + done: true, + }, + ], + text: "hello", +}; + +const anthropic = { + ...openai, + model: "claude-smoke", + options: { baseUrl: "https://provider.invalid", apiKey: "sk-smoke" }, + // `reasoning_effort` is omitted: this dialect rejects the field outright, so Brain's real + // prefix cannot be sent here until that guard also ignores an unset null. + generation: sealedPrefix({ max_tokens: 128 }), + body: (body) => sealedRequest(body, ["temperature", "stop_sequences"]), + chunks: [ + { + status: 200, + body: sse([ + `event: content_block_delta\ndata: ${JSON.stringify({ index: 0, delta: { type: "text_delta", text: "hel" } })}`, + ]), + }, + { + body: sse([ + `event: content_block_delta\ndata: ${JSON.stringify({ index: 0, delta: { type: "text_delta", text: "lo" } })}`, + `event: message_delta\ndata: ${JSON.stringify({ delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })}`, + ]), + done: true, + }, + ], +}; + +const environmentTarget = { kind: "environment", component: "dist/environment.component.wasm" }; + +const plan = { + agentloop: [{ kind: "agentloop", component: buildAgentloopFixture }], + "env-app": [ + environmentTarget, + { + kind: "tool", + component: "dist/tool.component.wasm", + config: { descriptor: { runtime: "callback", tool_name: "callback" } }, + grant: "environment", + toolName: "callback", + input: { command: "true" }, + value: { ok: true }, + }, + ], + "env-aws-microvm": [environmentTarget], + "loop-codex": [{ kind: "agentloop", component: "dist/loop.component.wasm" }], + "loop-pi": [{ kind: "agentloop", component: "dist/loop.component.wasm" }], + model: [ + { + kind: "model", + component: buildModelFixture, + model: "smoke-model", + options: { baseUrl: "https://provider.invalid" }, + generation: sealedPrefix({}), + body: (body) => assert.match(body, /Answer with one word\./u), + chunks: [ + { status: 200, body: sse([`data: ${JSON.stringify({ text: "hel" })}`]) }, + { body: sse([`data: ${JSON.stringify({ text: "lo" })}`]), done: true }, + ], + text: "hello", + }, + ], + "model-anthropic": [anthropic], + "model-openai": [openai], + tools: [ + { + kind: "tool", + component: "dist/tool.component.wasm", + configFile: "dist/bash.component.json", + grant: "environment", + toolName: "bash", + input: { command: "true" }, + value: { ok: true }, + }, + { + kind: "tool", + component: "dist/children.component.wasm", + grant: "children", + toolName: "subagents", + input: { action: "spawn_agent", message: "run the smoke", task_name: "smoke child" }, + value: { child_id: "chi_smoke", status: "running" }, + }, + ], +}; + +async function buildModelFixture(from, into) { + const { build } = await import("esbuild"); + const bundled = await build({ + entryPoints: [path.join(fixtures, "model.fixture.mjs")], + bundle: true, + format: "esm", + platform: "neutral", + external: ["aex:model/host@1.0.0"], + alias: { "@aexhq/model": path.join(from, "index.mjs") }, + write: false, + legalComments: "none", + }); + const { componentize } = await import("@bytecodealliance/componentize-js"); + const wit = await readFile(new URL(import.meta.resolve("@aexhq/brain/contracts/model")), "utf8"); + const output = await componentize(bundled.outputFiles[0].text, wit, { + worldName: "model", + disableFeatures: ["http", "fetch-event"], + }); + const file = path.join(into, "model.fixture.component.wasm"); + await writeFile(file, output.component); + return file; +} + +async function buildAgentloopFixture(from, into) { + const { buildAgentloopComponent } = await import( + pathToFileURL(path.join(from, "dist", "build.js")).href + ); + const { componentize } = await import("@bytecodealliance/componentize-js"); + const built = await buildAgentloopComponent( + { entry: path.join(fixtures, "agentloop.fixture.mjs") }, + componentize, + ); + const file = path.join(into, "agentloop.fixture.component.wasm"); + await writeFile(file, built.component); + return file; +} + +const [workspace, ...flags] = process.argv.slice(2); +const option = (name) => { + const index = flags.indexOf(`--${name}`); + return index < 0 ? undefined : flags[index + 1]; +}; +const targets = plan[workspace]; +if (targets === undefined) throw new Error(`no component smoke is declared for ${workspace}`); +const from = path.resolve(option("from") ?? path.join(root, "packages", workspace)); +const receipt = option("receipt"); + +const scratch = await mkdtemp(path.join(tmpdir(), "extensions-component-smoke-")); +const components = []; +try { + for (const target of targets) { + const file = typeof target.component === "string" + ? path.join(from, target.component) + : await target.component(from, scratch); + const bytes = await readFile(file); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const host = new Host(componentHost); + try { + await scenarios[target.kind](host, { ...target, from }, { path: file, sha256 }); + } finally { + host.close(); + } + components.push({ component: path.basename(file), kind: target.kind, sha256 }); + process.stdout.write(`${workspace}: ${target.kind} ${path.basename(file)} ${sha256}\n`); + } +} finally { + await rm(scratch, { recursive: true, force: true }); +} + +if (receipt !== undefined) { + const document = JSON.parse(await readFile(path.join(from, "package.json"), "utf8")); + await writeFile(receipt, `${JSON.stringify({ + schema: 1, + workspace, + name: document.name, + version: document.version, + integrity: process.env.SMOKE_INTEGRITY ?? null, + components, + }, null, 2)}\n`); +} diff --git a/tools/component-smoke/agentloop.fixture.mjs b/tools/component-smoke/agentloop.fixture.mjs new file mode 100644 index 0000000..b4db8d7 --- /dev/null +++ b/tools/component-smoke/agentloop.fixture.mjs @@ -0,0 +1,22 @@ +// The smallest loop an author can write with `@aexhq/agentloop`: hydration replayed into one model +// round, one journal entry and an explicit finish. Compiled by the staged package's own builder, +// this is the only place its host binding and activation envelope run inside a real component. +import { defineAgentloop } from "@aexhq/agentloop"; + +let memory = []; + +export const { activate } = defineAgentloop({ + onSessionStart(start) { + memory = start.tail.flatMap((view) => + view.type === "user_message" ? [{ role: "user", content: view.content }] : [], + ); + }, + async onMessage(ctx, message) { + memory.push({ role: "user", content: message.content }); + const round = await ctx.model.stream({ messages: memory.slice() }); + await ctx.journal.append([ + { kind: "event", name: "smoke.round", data: { stop: round.stop_reason } }, + ]); + await ctx.turn.finish({ text: round.content.map((block) => block.text ?? "").join("") }); + }, +}); diff --git a/tools/component-smoke/model.fixture.mjs b/tools/component-smoke/model.fixture.mjs new file mode 100644 index 0000000..fef5615 --- /dev/null +++ b/tools/component-smoke/model.fixture.mjs @@ -0,0 +1,69 @@ +// The smallest Model an author can write with `@aexhq/model`: every export reports through the +// package's `typed` guard and every chunk reaches the package's SSE decoder as the component ABI +// delivers it. Compiled against the staged package, this is the only place those two contracts run +// inside a real component. +import { httpRead, httpStart } from "aex:model/host@1.0.0"; +import { SseDecoder, parseJson, terminal, typed } from "@aexhq/model"; + +const attempts = new Map(); + +export function start(request) { + return typed("start", () => { + const options = parseJson(request.providerOptionsJson, "providerOptionsJson"); + const started = httpStart(request.operationId, { + method: "POST", + url: `${options.baseUrl}/v1/smoke`, + headers: [["content-type", "application/json"]], + body: new TextEncoder().encode(request.generationJson), + credential: undefined, + deadlineAtMs: request.deadlineAtMs, + }); + attempts.set(started.requestId, { decoder: new SseDecoder(), sequence: 0 }); + return { providerOperationId: started.requestId }; + }); +} + +export function observe(providerOperationId, cursor) { + return typed("observe", () => { + const attempt = required(providerOperationId); + const chunk = httpRead(providerOperationId, cursor, 64 * 1024); + if (typeof chunk.status === "number" && (chunk.status < 200 || chunk.status >= 300)) { + throw new Error(`smoke HTTP status ${chunk.status}`); + } + const events = []; + for (const frame of attempt.decoder.feed(chunk.bytes)) { + attempt.sequence += 1; + events.push({ + cursor: `${chunk.cursor}:${attempt.sequence}`, + kind: "text-delta", + payloadJson: JSON.stringify({ index: 0, text: parseJson(frame.data, "smoke frame").text }), + }); + } + return { + state: chunk.done ? "completed" : "streaming", + events, + nextCursor: chunk.cursor, + terminalJson: chunk.done ? terminal("end_turn") : undefined, + }; + }); +} + +export function cancel(providerOperationId) { + return typed("cancel", () => { + required(providerOperationId); + attempts.delete(providerOperationId); + }); +} + +export function acknowledge(providerOperationId) { + return typed("acknowledge", () => { + required(providerOperationId); + attempts.delete(providerOperationId); + }); +} + +function required(providerOperationId) { + const attempt = attempts.get(providerOperationId); + if (attempt === undefined) throw new Error(`unknown Model attempt ${providerOperationId}`); + return attempt; +} diff --git a/tools/npm-release.mjs b/tools/npm-release.mjs index 10d0e5b..fd64b2d 100644 --- a/tools/npm-release.mjs +++ b/tools/npm-release.mjs @@ -51,6 +51,26 @@ const releasedIntegrity = (spec) => { export const versionSetByLatestChange = (packageJsonPatch) => /^\+\s*"version":/mu.test(packageJsonPatch); +/** + * Staging fans out per package, so the order has to come from the release itself: a package waits + * only for the exact versions it is built and installed against. Two waves cover this release; a + * deeper chain has to add a wave rather than publish a dependent before its dependency is visible. + */ +export const stageOrder = (packages) => { + if (packages.some((item) => !Array.isArray(item.needs))) { + throw new Error("this release manifest predates per-package staging; stage the release again"); + } + const base = packages.filter((item) => item.needs.length === 0).map((item) => item.workspace); + const dependents = packages.filter((item) => item.needs.length > 0); + for (const item of dependents) { + const late = item.needs.filter((need) => !base.includes(need)); + if (late.length > 0) { + throw new Error(`${item.name} needs ${late.join(", ")}, which no earlier stage wave publishes`); + } + } + return { base, dependents: dependents.map((item) => item.workspace) }; +}; + const assertReleasedVersionIsCurrent = (workspace, spec) => { const directory = `packages/${workspace}`; const commit = git(["log", "-1", "--format=%H", "--", directory]); @@ -87,8 +107,10 @@ const manifest = async (filename) => { async function pack(directory) { await mkdir(directory, { recursive: false }); const packages = []; + const documents = new Map(); for (const workspace of workspaces) { const packageDocument = await document(workspace); + documents.set(workspace, packageDocument); if (packageDocument.publishConfig?.access !== "public" || packageDocument.publishConfig?.tag !== "next") { throw new Error(`${packageDocument.name} must publish publicly under the next dist-tag`); } @@ -126,6 +148,7 @@ async function pack(directory) { peerDependencies: packageDocument.peerDependencies ?? {}, }); } + const owner = new Map(packages.map((item) => [item.name, item.workspace])); for (const item of packages) { for (const [name, version] of Object.entries(item.dependencies)) { const local = packages.find((candidate) => candidate.name === name); @@ -133,7 +156,15 @@ async function pack(directory) { throw new Error(`${item.name} must depend on the exact release version ${name}@${local.version}`); } } + // An authoring toolchain is a development edge and still orders staging: its dependent is + // built with it, and the exact version has to be on the registry before that happens. + const declared = documents.get(item.workspace); + item.needs = [...new Set(Object.keys({ ...declared.dependencies, ...declared.devDependencies }))] + .map((name) => owner.get(name)) + .filter((workspace) => workspace !== undefined && workspace !== item.workspace) + .sort(); } + stageOrder(packages); const value = { schema: 1, source: process.env.GITHUB_SHA ?? "local", packages }; await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(value, null, 2)}\n`); for (const filename of ["npm-release.mjs", "verify-dependencies.mjs", "publish.mjs"]) { @@ -147,7 +178,12 @@ const [command, argument] = process.argv[1] !== undefined && : ["import"]; if (command === "import") { /* imported for its exported contracts */ } else if (command === "pack" && argument !== undefined) await pack(path.resolve(argument)); -else if (command === "versions" && argument !== undefined) { +else if (command === "workspaces") process.stdout.write(JSON.stringify(workspaces)); +else if (command === "order" && argument !== undefined) { + const value = await manifest(path.resolve(argument)); + const { base, dependents } = stageOrder(value.packages); + process.stdout.write(JSON.stringify({ base, dependents })); +} else if (command === "versions" && argument !== undefined) { const value = await manifest(path.resolve(argument)); process.stdout.write(value.packages.map(({ name, version }) => `${name}@${version}`).join(",")); } else if (command === "markdown" && argument !== undefined) { @@ -157,5 +193,7 @@ else if (command === "versions" && argument !== undefined) { process.stdout.write(`| \`${item.name}\` | \`${item.version}\` | \`${item.integrity}\` |\n`); } } else { - throw new Error("usage: npm-release.mjs pack | versions|markdown "); + throw new Error( + "usage: npm-release.mjs pack | workspaces | order|versions|markdown ", + ); } diff --git a/tools/npm-release.test.mjs b/tools/npm-release.test.mjs index b35e5cc..6aa85f9 100644 --- a/tools/npm-release.test.mjs +++ b/tools/npm-release.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { versionSetByLatestChange } from "./npm-release.mjs"; +import { stageOrder, versionSetByLatestChange } from "./npm-release.mjs"; test("a released package must take a new version with its latest change", () => { assert.equal(versionSetByLatestChange([ @@ -20,3 +20,22 @@ test("a released package must take a new version with its latest change", () => assert.equal(versionSetByLatestChange(""), false); }); + +test("staging waves come from the release, not from a list someone maintains", () => { + assert.deepEqual(stageOrder([ + { workspace: "model", name: "@aexhq/model", needs: [] }, + { workspace: "agentloop", name: "@aexhq/agentloop", needs: [] }, + { workspace: "model-openai", name: "@aexhq/model-openai", needs: ["model"] }, + { workspace: "loop-pi", name: "@aexhq/loop-pi", needs: ["agentloop"] }, + ]), { base: ["model", "agentloop"], dependents: ["model-openai", "loop-pi"] }); + + // A dependent published before the exact version it installs is visible cannot resolve, so a + // deeper chain has to add a wave rather than fan out anyway. + assert.throws(() => stageOrder([ + { workspace: "model", name: "@aexhq/model", needs: [] }, + { workspace: "model-openai", name: "@aexhq/model-openai", needs: ["model"] }, + { workspace: "loop-pi", name: "@aexhq/loop-pi", needs: ["model-openai"] }, + ]), /@aexhq\/loop-pi needs model-openai/u); + + assert.throws(() => stageOrder([{ workspace: "model", name: "@aexhq/model" }]), /predates/u); +}); diff --git a/tools/publish.mjs b/tools/publish.mjs index 2ba2de8..5b6c240 100644 --- a/tools/publish.mjs +++ b/tools/publish.mjs @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; +import { stageOrder } from "./npm-release.mjs"; + const npmCli = [ process.env.npm_execpath, path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), @@ -90,7 +92,27 @@ const publishHoldPlaceholder = async (item) => { process.stdout.write(`reserved ${item.name}@latest with ${placeholderSpec} (${integrity})\n`); }; +/** + * A staged version is promotable only with a passing component smoke of that exact registry + * object: nothing else proves the published bytes run as a component against the host ABI. + */ +const assertSmoked = (item) => { + const directory = process.env.SMOKE_RECEIPTS; + if (directory === undefined) throw new Error("SMOKE_RECEIPTS is unavailable"); + const receipt = JSON.parse(readFileSync(path.join(directory, `${item.workspace}.json`), "utf8")); + if (receipt.name !== item.name || receipt.version !== item.version || + receipt.integrity !== item.integrity) { + throw new Error(`the component smoke covered ${receipt.name}@${receipt.version} (${receipt.integrity})`); + } +}; + const operation = process.argv[2]; +const workspace = process.argv[3]; +const selected = workspace === undefined + ? manifest.packages + : manifest.packages.filter((item) => item.workspace === workspace); +if (selected.length === 0) throw new Error(`the release manifest has no ${workspace} package`); + if (operation === "bootstrap") { if (!process.env.NODE_AUTH_TOKEN) { throw new Error("the protected npm-production environment has no NPM_DIST_TAG_TOKEN"); @@ -143,7 +165,7 @@ if (operation === "bootstrap") { } } else if (operation === "stage") { const existing = new Map(); - for (const item of manifest.packages) { + for (const item of selected) { const spec = `${item.name}@${item.version}`; const integrity = registryValue(spec, "dist.integrity"); if (integrity !== undefined && integrity !== item.integrity) { @@ -151,7 +173,7 @@ if (operation === "bootstrap") { } existing.set(spec, integrity); } - for (const item of manifest.packages) { + for (const item of selected) { const spec = `${item.name}@${item.version}`; if (existing.get(spec) === undefined) { run(["publish", path.join(directory, item.filename), "--access", "public", "--tag", "next", "--provenance"], "inherit"); @@ -162,19 +184,31 @@ if (operation === "bootstrap") { } } else if (operation === "promote") { if (!process.env.NODE_AUTH_TOKEN) throw new Error("NPM_DIST_TAG_TOKEN is unavailable"); - for (const item of manifest.packages) { - assertRegistryObject(item); - const staged = registryValue(`${item.name}@next`, "version"); - if (staged !== item.version) { - throw new Error(`${item.name}@next is ${staged ?? "absent"}; refusing promotion`); + const order = stageOrder(selected); + const failures = []; + // Each package carries its own evidence, so one unproven package stays staged instead of + // holding back the release. Dependency order still decides who moves first. + for (const workspace of [...order.base, ...order.dependents]) { + const item = selected.find((candidate) => candidate.workspace === workspace); + const spec = `${item.name}@${item.version}`; + try { + assertSmoked(item); + assertRegistryObject(item); + const staged = registryValue(`${item.name}@next`, "version"); + if (staged !== item.version) { + throw new Error(`${item.name}@next is ${staged ?? "absent"}`); + } + run(["dist-tag", "add", spec, "latest"], "inherit"); + await waitFor(() => registryValue(`${item.name}@latest`, "version"), item.version, `${item.name}@latest`); + process.stdout.write(`promoted ${spec} without republishing\n`); + } catch (error) { + failures.push(`${spec}: ${error.message}`); + process.stdout.write(`held ${spec} on next: ${error.message}\n`); } } - for (const item of manifest.packages) { - const spec = `${item.name}@${item.version}`; - run(["dist-tag", "add", spec, "latest"], "inherit"); - await waitFor(() => registryValue(`${item.name}@latest`, "version"), item.version, `${item.name}@latest`); - process.stdout.write(`promoted ${spec} without republishing\n`); + if (failures.length > 0) { + throw new Error(`${failures.length} package(s) stayed on next:\n${failures.join("\n")}`); } } else { - throw new Error("usage: publish.mjs bootstrap|stage|hold|promote"); + throw new Error("usage: publish.mjs bootstrap|hold|promote | stage "); } diff --git a/tools/publish.test.mjs b/tools/publish.test.mjs new file mode 100644 index 0000000..679d1e8 --- /dev/null +++ b/tools/publish.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const registry = { + integrity: { + "@aexhq/model@0.1.2": "sha512-model", + "@aexhq/model-openai@0.1.3": "sha512-openai", + }, + next: { "@aexhq/model": "0.1.2", "@aexhq/model-openai": "0.1.3" }, + latest: { "@aexhq/model": "0.0.0", "@aexhq/model-openai": "0.0.0" }, +}; + +const manifest = { + schema: 1, + source: "0".repeat(40), + packages: [ + { + workspace: "model-openai", + name: "@aexhq/model-openai", + version: "0.1.3", + integrity: "sha512-openai", + needs: ["model"], + }, + { + workspace: "model", + name: "@aexhq/model", + version: "0.1.2", + integrity: "sha512-model", + needs: [], + }, + ], +}; + +const npmStub = ` + import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; + const args = process.argv.slice(2); + const state = JSON.parse(readFileSync(process.env.REGISTRY, "utf8")); + const parse = (spec) => [spec.slice(0, spec.lastIndexOf("@")), spec.slice(spec.lastIndexOf("@") + 1)]; + if (args[0] === "dist-tag") { + const [name, version] = parse(args[2]); + appendFileSync(process.env.PROMOTED, args[2] + "\\n"); + state.latest[name] = version; + writeFileSync(process.env.REGISTRY, JSON.stringify(state)); + } else { + const [name, selector] = parse(args[1]); + const value = args[2] === "dist.integrity" + ? state.integrity[args[1]] + : selector === "next" ? state.next[name] : state.latest[name]; + if (value === undefined) process.exitCode = 1; + else process.stdout.write(JSON.stringify(value)); + } +`; + +const promote = async (receipts) => { + const directory = await mkdtemp(path.join(tmpdir(), "extensions-promote-")); + const state = path.join(directory, "registry.json"); + const promoted = path.join(directory, "promoted.txt"); + const smoke = path.join(directory, "smoke"); + await mkdir(smoke); + await writeFile(state, JSON.stringify(registry)); + await writeFile(promoted, ""); + await writeFile(path.join(directory, "manifest.json"), JSON.stringify(manifest)); + await writeFile(path.join(directory, "npm-cli.mjs"), npmStub); + for (const [workspace, receipt] of Object.entries(receipts)) { + await writeFile(path.join(smoke, `${workspace}.json`), JSON.stringify(receipt)); + } + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("./publish.mjs", import.meta.url)), "promote"], + { + encoding: "utf8", + env: { + ...process.env, + EXPECTED_COMMIT: manifest.source, + NODE_AUTH_TOKEN: "token", + PROMOTED: promoted, + REGISTRY: state, + RELEASE_MANIFEST: path.join(directory, "manifest.json"), + SMOKE_RECEIPTS: smoke, + npm_execpath: path.join(directory, "npm-cli.mjs"), + }, + }, + ); + return { ...result, promoted: (await readFile(promoted, "utf8")).split("\n").filter(Boolean) }; +}; + +const receiptFor = (workspace) => { + const item = manifest.packages.find((candidate) => candidate.workspace === workspace); + return { schema: 1, workspace, name: item.name, version: item.version, integrity: item.integrity }; +}; + +test("promotion moves each package on its own evidence, in dependency order", async () => { + const result = await promote({ + model: receiptFor("model"), + "model-openai": receiptFor("model-openai"), + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.promoted, ["@aexhq/model@0.1.2", "@aexhq/model-openai@0.1.3"]); +}); + +test("promotion fails closed when the exact version has no passing component smoke", async () => { + const missing = await promote({ model: receiptFor("model") }); + assert.notEqual(missing.status, 0); + assert.deepEqual(missing.promoted, ["@aexhq/model@0.1.2"]); + assert.match(missing.stderr, /1 package\(s\) stayed on next/u); + + // A receipt for other bytes is not evidence for these: componentize-js never rebuilds a + // component byte-for-byte, so only the registry integrity ties a smoke to the staged archive. + const stale = await promote({ + model: receiptFor("model"), + "model-openai": { ...receiptFor("model-openai"), integrity: "sha512-rebuilt" }, + }); + assert.notEqual(stale.status, 0); + assert.deepEqual(stale.promoted, ["@aexhq/model@0.1.2"]); + assert.match(stale.stdout, /held @aexhq\/model-openai@0\.1\.3/u); +}); diff --git a/tools/verify-dependencies.test.mjs b/tools/verify-dependencies.test.mjs index 2d07289..0fcd60e 100644 --- a/tools/verify-dependencies.test.mjs +++ b/tools/verify-dependencies.test.mjs @@ -70,6 +70,13 @@ test("promotion verifies the staged manifest with the current workflow source", promote, /EXPECTED_COMMIT: \$\{\{ needs\.validate\.outputs\.release_sha \}\}/u, ); + // Without these the job cannot see the stage run's receipts, and every promotion fails closed + // mid-release instead of at the pull request that dropped them. + assert.match( + promote, + /pattern: extensions-npm-smoke-\$\{\{ needs\.validate\.outputs\.release_sha \}\}-\*/u, + ); + assert.match(promote, /SMOKE_RECEIPTS: \$\{\{ runner\.temp \}\}\/extensions-npm-smoke/u); assert.doesNotMatch(promote, /node "\$RUNNER_TEMP\/extensions-npm-release\/verify-dependencies\.mjs"/u); assert.doesNotMatch(workflow, /test "\$release_sha" = "\$EXPECTED_COMMIT"/u); }); From ecd466c721651e6e53ae13b77b44ea9b93ae1e6d Mon Sep 17 00:00:00 2001 From: Weilue Luo Date: Wed, 26 Aug 2026 02:06:58 +0100 Subject: [PATCH 2/2] release: name the promotion loop variable for its role --- tools/publish.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/publish.mjs b/tools/publish.mjs index 5b6c240..c5c874b 100644 --- a/tools/publish.mjs +++ b/tools/publish.mjs @@ -188,8 +188,8 @@ if (operation === "bootstrap") { const failures = []; // Each package carries its own evidence, so one unproven package stays staged instead of // holding back the release. Dependency order still decides who moves first. - for (const workspace of [...order.base, ...order.dependents]) { - const item = selected.find((candidate) => candidate.workspace === workspace); + for (const member of [...order.base, ...order.dependents]) { + const item = selected.find((candidate) => candidate.workspace === member); const spec = `${item.name}@${item.version}`; try { assertSmoked(item);