diff --git a/.github/workflows/ab-report.yml b/.github/workflows/ab-report.yml new file mode 100644 index 0000000..f76038b --- /dev/null +++ b/.github/workflows/ab-report.yml @@ -0,0 +1,143 @@ +name: A/B report (vanilla vs Pilot) + +# When a publish PR touches submissions//, run the equivalent commands two +# ways — the vanilla CLI binary vs the Pilot adapter (socket mode, no daemon) — +# and post an HTML A/B report. Limitation: GitHub-hosted runners have no nested +# virtualization (KVM), so VM-launching commands cannot run here. The per-app +# command set (submissions//ab-commands.json) must therefore stay non-VM +# (version / help / subcommand --help). See docs/CI-AB-REPORT.md. + +on: + pull_request: + paths: ['submissions/**'] + workflow_dispatch: + inputs: + app_id: + description: 'App id, e.g. io.pilot.smolvm' + required: true + +permissions: + contents: read + pull-requests: write + +jobs: + ab-report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Detect app id + id: app + run: | + set -euo pipefail + if [ -n "${{ github.event.inputs.app_id }}" ]; then + APP="${{ github.event.inputs.app_id }}" + else + APP=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- 'submissions/**' \ + | sed -nE 's#^submissions/([^/]+)/.*#\1#p' | sort -u | head -1) + fi + if [ -z "$APP" ]; then echo "::error::could not detect an app id under submissions/"; exit 1; fi + echo "app=$APP" >> "$GITHUB_OUTPUT" + echo "Detected app: $APP" + + - name: Build ipc-call + run: go build -o /tmp/ipc-call ./cmd/ipc-call + + - name: Extract bundle and stage the adapter + id: stage + run: | + set -euo pipefail + APP="${{ steps.app.outputs.app }}" + DIR="submissions/$APP" + # Prefer the linux/amd64 bundle (this runner's arch); fall back to any. + BUNDLE=$(ls "$DIR"/*linux-amd64*.tar.gz 2>/dev/null | head -1 || true) + [ -n "$BUNDLE" ] || BUNDLE=$(ls "$DIR"/*.tar.gz 2>/dev/null | head -1 || true) + if [ -z "$BUNDLE" ]; then + echo "::notice::$APP ships no bundle tarball under $DIR (metadata-only change, or the binary is delivered out-of-band) — A/B report not applicable" + echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "Using bundle: $BUNDLE" + ROOT=/tmp/app; rm -rf "$ROOT"; mkdir -p "$ROOT"; tar -xzf "$BUNDLE" -C "$ROOT" + + if [ ! -f "$ROOT/install.json" ]; then + echo "::notice::$APP ships no install.json (HTTP app, or a cli whose binary is not delivered) — A/B report not applicable" + echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0 + fi + + NS=$(jq -r '.namespace // empty' "$DIR/submission.json" 2>/dev/null || true) + [ -n "$NS" ] || NS="${APP##*.}" + BIN=$(jq -r '.binary.path' "$ROOT/manifest.json") + chmod +x "$ROOT/$BIN" + CMD=$(jq -r '.command' "$ROOT/install.json") + EXEC=$(jq -r --arg c "$CMD" '.assets[] | select((.exec_path|split("/")|last)==$c or .exec_path==$c) | .exec_path' "$ROOT/install.json" | head -1) + if [ -z "$EXEC" ]; then echo "::error::could not resolve the command exec_path from install.json"; exit 1; fi + echo "ns=$NS root=$ROOT bin=$BIN exec=$EXEC" + { + echo "ns=$NS"; echo "root=$ROOT"; echo "exec=$EXEC"; + } >> "$GITHUB_OUTPUT" + + # Run the adapter exactly as the daemon would; it stages the host's + # artifacts from the R2 registry on startup (needs network egress). + "$ROOT/$BIN" --socket "$ROOT/app.sock" --manifest "$ROOT/manifest.json" > /tmp/adapter.log 2>&1 & + echo $! > /tmp/adapter.pid + for i in $(seq 1 90); do [ -S "$ROOT/app.sock" ] && break; sleep 2; done + if [ ! -S "$ROOT/app.sock" ]; then + echo "::error::adapter socket never appeared (staging from R2 likely failed for this runner's os/arch)" + echo "----- adapter log -----"; cat /tmp/adapter.log || true + exit 1 + fi + echo "Adapter up; staged binary: $ROOT/$EXEC" + + - name: Run A/B report + if: steps.stage.outputs.skip != '1' + run: | + set -euo pipefail + ROOT="${{ steps.stage.outputs.root }}" + python3 scripts/ab_report.py \ + --app "${{ steps.app.outputs.app }}" --ns "${{ steps.stage.outputs.ns }}" \ + --mode socket --socket "$ROOT/app.sock" --ipc-call /tmp/ipc-call \ + --vanilla "$ROOT/${{ steps.stage.outputs.exec }}" \ + --submissions ./submissions --out ab-report.html + + - name: Stop adapter + if: always() + run: '[ -f /tmp/adapter.pid ] && kill "$(cat /tmp/adapter.pid)" 2>/dev/null || true' + + - name: Upload report + if: steps.stage.outputs.skip != '1' + uses: actions/upload-artifact@v4 + with: + name: ab-report-${{ steps.app.outputs.app }} + path: ab-report.html + + - name: Comment on the PR + if: steps.stage.outputs.skip != '1' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const app = '${{ steps.app.outputs.app }}'; + const html = fs.readFileSync('ab-report.html', 'utf8'); + const re = /([^<]+)<\/td>(\d+)<\/td>(\d+)<\/td>([^<]+)<\/td>([^<]+)<\/td><\/tr>/g; + let table = '| Command | Vanilla ms | Pilot ms | Δ | Exit match |\n|---|--:|--:|--:|:--:|\n'; + let n = 0; + for (const m of html.matchAll(re)) { table += `| ${m[1]} | ${m[2]} | ${m[3]} | ${m[4]} | ${m[5]} |\n`; n++; } + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const marker = ``; + const body = `${marker}\n### A/B report — \`${app}\` (vanilla vs Pilot)\n\n` + + `Equivalent commands run two ways: the vanilla CLI binary vs the Pilot adapter (socket mode). ` + + `CI runs **non-VM** commands only (GitHub runners have no KVM). ` + + `Full HTML report (commands, outputs, timings, adapter-generated help): ` + + `**[download from the run artifacts](${runUrl})**.\n\n${n ? table : '_No command pairs ran._'}`; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number }); + const prev = comments.find(c => c.body && c.body.includes(marker)); + if (prev) await github.rest.issues.updateComment({ owner, repo, comment_id: prev.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number, body }); diff --git a/cmd/publish-server/main.go b/cmd/publish-server/main.go index 30c0bcb..a8eeaf7 100644 --- a/cmd/publish-server/main.go +++ b/cmd/publish-server/main.go @@ -294,7 +294,7 @@ func (s *server) apiArtifactPresign(w http.ResponseWriter, r *http.Request) { // installs work off a stable URL even when the bucket has no public domain. func (s *server) artifactProxy(w http.ResponseWriter, r *http.Request) { if s.r2 == nil { - http.Error(w, "artifact registry not configured", 503) + http.Error(w, "artifact registry not configured", http.StatusServiceUnavailable) return } key := strings.TrimPrefix(r.URL.Path, "/artifact/") diff --git a/docs/CI-AB-REPORT.md b/docs/CI-AB-REPORT.md new file mode 100644 index 0000000..9290129 --- /dev/null +++ b/docs/CI-AB-REPORT.md @@ -0,0 +1,63 @@ +# CI: per-app vanilla-vs-Pilot A/B report + +`.github/workflows/ab-report.yml` runs an A/B report when a **publish PR** touches +`submissions//`. It runs each equivalent command two ways — the vanilla CLI +binary vs the Pilot adapter — and posts an HTML report (commands, outputs, exit +codes, timings, and the adapter-generated `.help`). + +## How it works + +1. **Detect** the app id from the changed `submissions//` path (or the + `workflow_dispatch` `app_id` input). +2. **Extract** the committed bundle (prefers `*linux-amd64*.tar.gz` for the + ubuntu runner) → `manifest.json`, `bin/`, `install.json`. +3. **Stage**: run the adapter with `--socket/--manifest`. On startup it fetches + this host's artifacts from the R2 registry (per `install.json`), sha-verifies, + and stages them — exactly as the daemon-spawned adapter does. The staged + `exec_path` binary is the *vanilla* side. +4. **Run** `scripts/ab_report.py --mode socket`, driving the adapter through + `cmd/ipc-call` (no daemon needed), with the per-app command set. +5. **Publish**: upload `ab-report.html` as a run artifact and upsert a PR comment + with the summary table + a link to the artifact. + +## ⚠️ No VM boots in CI + +GitHub-hosted runners have **no nested virtualization (KVM)**, so VM-launching +commands (`smolvm machine run …`) cannot run there. Keep the CI command set to +non-VM commands that still prove the adapter forwards the full surface: +`--version`, `--help`, and subcommand `--help` (e.g. `machine --help`, +`pack --help`). Run microVM workloads locally with `--mode pilotctl` against a +daemon (see `scripts/ab_report.py`). + +## Per-app command set + +Add `submissions//ab-commands.json`: + +```json +{ + "commands": [ + {"label": "Version", "vanilla": ["--version"], "method": ".version", "payload": {}}, + {"label": "machine --help", "vanilla": ["machine","--help"], + "method": ".exec", "payload": {"args": ["machine","--help"]}} + ] +} +``` + +- `vanilla` — argv passed to the staged binary directly. +- `method` + `payload` — the adapter method and JSON args (use the enumerated + method for `version`; the passthrough `.exec` for everything else). + +If the file is absent, a built-in default runs `--version` and `--help` via the +passthrough exec method. See `submissions/io.pilot.smolvm/ab-commands.json`. + +## Requirements / limitations + +- **Platform artifact**: the report runs on `ubuntu-latest`, so the submission's + R2 artifacts must include a **linux/amd64** build for the adapter to stage. + Apps that ship only other platforms (e.g. darwin/arm64) will skip with a clear + error — point the workflow at a matching runner if needed (e.g. `macos-14` for + darwin/arm64-only apps). +- **Applicability**: only cli apps that **deliver a binary** (ship `install.json`) + get a report. HTTP apps and cli apps whose binary is assumed-present are + skipped with a notice. +- **Network**: the runner needs egress to the R2 public URL to stage artifacts. diff --git a/internal/publish/r2.go b/internal/publish/r2.go index 0a92739..47a3df3 100644 --- a/internal/publish/r2.go +++ b/internal/publish/r2.go @@ -5,7 +5,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "net/url" "os" "sort" "strconv" @@ -179,18 +178,3 @@ func rfc3986Escape(s string, encodeSlash bool) string { } return b.String() } - -// parsePublicKey extracts the object key from a public/proxy URL, for validating -// that a submitted artifact url points into our registry. -func (r *R2) keyFromURL(raw string) (string, bool) { - u, err := url.Parse(raw) - if err != nil { - return "", false - } - if r.PublicBase != "" { - if pb, err := url.Parse(r.PublicBase); err == nil && u.Host == pb.Host { - return strings.TrimPrefix(u.Path, "/"), true - } - } - return "", false -} diff --git a/scripts/ab_report.py b/scripts/ab_report.py index 44c4b5d..cfb3d08 100755 --- a/scripts/ab_report.py +++ b/scripts/ab_report.py @@ -2,117 +2,154 @@ """ab_report.py — vanilla-vs-Pilot A/B report for a native cli app. Runs a set of EQUIVALENT commands two ways — the vanilla CLI binary directly, -and through the Pilot app store (`pilotctl appstore call ...`) — capturing -each command's output, exit code, and wall-clock time, plus the adapter's -generated `.help` document. Emits a self-contained HTML report. - -Reused by CI (.github/workflows): on a PR tied to an app, build + install that -app, then run this to produce the report artifact. - -Env / args: - PILOT_APPSTORE_ROOT, PILOT_APPSTORE_CATALOG_URL passed through to pilotctl -Usage: - ab_report.py --app io.pilot.smolvm --ns smolvm \ - --pilot /path/to/pilotctl --vanilla /path/to/smolvm --out report.html +and through the Pilot adapter — capturing each command's output, exit code, and +wall-clock time, plus the adapter's generated `.help` document, and emits a +self-contained HTML report. + +Two drive modes for the Pilot side: + --mode pilotctl `pilotctl appstore call ` (needs a daemon) + --mode socket drive the adapter's unix socket directly via cmd/ipc-call + (no daemon — used in CI; build adapter, run it with + --socket/--manifest, it stages artifacts from R2 on startup) + +The command set is loaded from `submissions//ab-commands.json` when present, +else a CI-safe default (version + help) is used. CI-safe = NO microVM boots: +GitHub-hosted runners have no nested virtualization/KVM, so VM-launching commands +(`smolvm machine run ...`) cannot run there — keep CI commands to --version, +--help, and subcommand --help. + +Usage (socket, CI): + ab_report.py --app io.pilot.smolvm --ns smolvm --mode socket \ + --socket $APP/app.sock --ipc-call ./ipc-call \ + --vanilla $APP/ --submissions ./submissions --out ab-report.html +Usage (pilotctl, local): + ab_report.py --app io.pilot.smolvm --ns smolvm --mode pilotctl \ + --pilot /path/to/pilotctl --vanilla /path/to/smolvm --out ab-report.html """ -import argparse, html, json, os, subprocess, sys, time +import argparse, html, json, os, subprocess, time -def run(cmd, stdin=None, env=None): +def run(cmd, stdin=None, env=None, timeout=600): """Run argv, return (stdout, stderr, exit, ms).""" t0 = time.time() try: p = subprocess.run(cmd, capture_output=True, text=True, env=env, - input=stdin, timeout=600) - ms = int((time.time() - t0) * 1000) - return p.stdout, p.stderr, p.returncode, ms + input=stdin, timeout=timeout) + return p.stdout, p.stderr, p.returncode, int((time.time() - t0) * 1000) except subprocess.TimeoutExpired: - return "", "TIMEOUT after 600s", 124, int((time.time() - t0) * 1000) + return "", f"TIMEOUT after {timeout}s", 124, int((time.time() - t0) * 1000) + except FileNotFoundError as e: + return "", f"not found: {e}", 127, 0 -def pilot_call(pilot, app, method, payload, env): - """pilotctl appstore call → (raw, reply_obj, ms).""" - out, err, code, ms = run([pilot, "appstore", "call", app, method, - json.dumps(payload), "--timeout", "8m"], env=env) - reply = None - blob = out +def first_json(blob): + """Extract the first top-level JSON object from mixed stdout.""" i = blob.find("{") - if i >= 0: - j = blob.rfind("}") - try: - reply = json.loads(blob[i:j + 1]) - except Exception: - reply = None - return (out + ("\n" + err if err.strip() else ""), reply, ms) + if i < 0: + return None + try: + return json.loads(blob[i:blob.rfind("}") + 1]) + except Exception: + return None + + +class Pilot: + """Dispatches a method call to the adapter, either via pilotctl or the socket.""" + + def __init__(self, a, env): + self.mode, self.a, self.env = a.mode, a, env + + def call(self, method, payload): + if self.mode == "pilotctl": + cmd = [self.a.pilot, "appstore", "call", self.a.app, method, + json.dumps(payload), "--timeout", "8m"] + label = f"pilotctl appstore call {self.a.app} {method} '{json.dumps(payload)}'" + else: # socket + cmd = [self.a.ipc_call, "-socket", self.a.socket, "-method", method, + "-args", json.dumps(payload)] + label = f"ipc-call -socket $APP/app.sock -method {method} -args '{json.dumps(payload)}'" + out, err, code, ms = run(cmd, env=self.env) + raw = out + ("\n" + err if err.strip() else "") + return raw, first_json(out), ms, label def reply_view(reply, raw): - """Normalize a pilot reply to (stdout, stderr, exit).""" + """Normalize an adapter reply to (stdout, stderr, exit).""" if isinstance(reply, dict) and "exit" in reply: return reply.get("stdout", ""), reply.get("stderr", ""), reply.get("exit", "") - # enumerated/help replies are raw JSON objects return (json.dumps(reply, indent=2) if reply is not None else raw), "", 0 +def load_commands(a): + """Per-app command set from submissions//ab-commands.json, else a + CI-safe default (version + help via the passthrough exec method).""" + path = a.commands + if not path and a.submissions: + path = os.path.join(a.submissions, a.app, "ab-commands.json") + if path and os.path.exists(path): + doc = json.load(open(path)) + cmds = doc["commands"] if isinstance(doc, dict) else doc + return cmds, path + ex = f"{a.ns}.exec" + return [ + {"label": "Version", "note": "passthrough → ` --version`", + "vanilla": ["--version"], "method": ex, "payload": {"args": ["--version"]}}, + {"label": "Help", "note": "passthrough → ` --help`", + "vanilla": ["--help"], "method": ex, "payload": {"args": ["--help"]}}, + ], "(built-in default: version + help)" + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--app", required=True) ap.add_argument("--ns", required=True) - ap.add_argument("--pilot", required=True) - ap.add_argument("--vanilla", required=True) + ap.add_argument("--mode", choices=["pilotctl", "socket"], default="pilotctl") + ap.add_argument("--vanilla", required=True, help="the staged CLI binary (vanilla side)") + ap.add_argument("--pilot", help="pilotctl binary (mode=pilotctl)") + ap.add_argument("--socket", help="adapter unix socket (mode=socket)") + ap.add_argument("--ipc-call", dest="ipc_call", help="cmd/ipc-call binary (mode=socket)") + ap.add_argument("--submissions", help="submissions/ dir (to find /ab-commands.json)") + ap.add_argument("--commands", help="explicit ab-commands.json path") ap.add_argument("--out", default="ab-report.html") a = ap.parse_args() + if a.mode == "pilotctl" and not a.pilot: + ap.error("--pilot is required for --mode pilotctl") + if a.mode == "socket" and not (a.socket and a.ipc_call): + ap.error("--socket and --ipc-call are required for --mode socket") env = dict(os.environ) + pilot = Pilot(a, env) - # Equivalent command pairs. Each: label, the smolvm argv, the pilot method + - # payload, and whether it boots a VM (for the note). - argv_run = ["machine", "run", "--net", "--image", "alpine", "--", "sh", "-c", - "echo hello from microVM; uname -a; cat /etc/alpine-release"] - argv_py = ["machine", "run", "--net", "--image", "python:3.12-alpine", "--", - "python3", "-c", "print('2**100 =', 2**100)"] - pairs = [ - dict(label="Version", note="enumerated method → `smolvm --version`", - vanilla=["--version"], method=f"{a.ns}.version", payload={}), - dict(label="List machines", note="passthrough → `smolvm machine ls`", - vanilla=["machine", "ls"], method=f"{a.ns}.exec", - payload={"args": ["machine", "ls"]}), - dict(label="Run command in an ephemeral Alpine microVM", - note="boots a real isolated VM (separate kernel)", - vanilla=argv_run, method=f"{a.ns}.exec", payload={"args": argv_run}), - dict(label="Compute in a Python microVM", - note="pulls python:3.12-alpine, runs Python in the VM", - vanilla=argv_py, method=f"{a.ns}.exec", payload={"args": argv_py}), - ] - + pairs, src = load_commands(a) rows = [] for p in pairs: vout, verr, vcode, vms = run([a.vanilla] + p["vanilla"], env=env) - praw, preply, pms = pilot_call(a.pilot, a.app, p["method"], p["payload"], env) + praw, preply, pms, plabel = pilot.call(p["method"], p["payload"]) pout, perr, pcode = reply_view(preply, praw) - rows.append(dict(p=p, vanilla=dict(cmd=" ".join([os.path.basename(a.vanilla)] + p["vanilla"]), - out=vout, err=verr, code=vcode, ms=vms), - pilot=dict(cmd=f"pilotctl appstore call {a.app} {p['method']} '{json.dumps(p['payload'])}'", - out=pout, err=perr, code=pcode, ms=pms))) + rows.append(dict(p=p, + vanilla=dict(cmd=" ".join([os.path.basename(a.vanilla)] + p["vanilla"]), + out=vout, err=verr, code=vcode, ms=vms), + pilot=dict(cmd=plabel, out=pout, err=perr, code=pcode, ms=pms))) - # Adapter-generated help document. - hraw, hreply, hms = pilot_call(a.pilot, a.app, f"{a.ns}.help", {}, env) + hraw, hreply, hms, _ = pilot.call(f"{a.ns}.help", {}) help_doc = json.dumps(hreply, indent=2) if hreply else hraw vhelp_out, _, _, vhelp_ms = run([a.vanilla, "--help"], env=env) - render(a, rows, help_doc, hms, vhelp_out, vhelp_ms) - print(f"wrote {a.out}") + render(a, rows, help_doc, hms, vhelp_out, vhelp_ms, src) + print(f"wrote {a.out} ({len(rows)} command pairs, mode={a.mode}, commands={src})") def esc(s): return html.escape(s if isinstance(s, str) else str(s)) -def render(a, rows, help_doc, hms, vhelp_out, vhelp_ms): +def render(a, rows, help_doc, hms, vhelp_out, vhelp_ms, src): + vcli = os.path.basename(a.vanilla) + def block(d): cls = "ok" if d["code"] == 0 else "bad" body = esc(d["out"].rstrip()) if d["err"].strip(): - body += f'\n── stderr ──\n' + esc(d["err"].rstrip()) + body += '\n── stderr ──\n' + esc(d["err"].rstrip()) return (f'
{esc(d["cmd"])}
' f'
exit {d["code"]}' f'{d["ms"]} ms
' @@ -125,10 +162,10 @@ def block(d): cards.append(f"""

{esc(r['p']['label'])}

-

{esc(r['p']['note'])}

+

{esc(r['p'].get('note',''))}

Vanilla CLI
{block(v)}
-
Pilot app store
{block(pl)}
+
Pilot adapter
{block(pl)}
adapter overhead: {'+' if delta>=0 else ''}{delta} ms (vanilla {v['ms']} ms · pilot {pl['ms']} ms)
@@ -138,7 +175,7 @@ def block(d): f"{esc(r['p']['label'])}{r['vanilla']['ms']}" f"{r['pilot']['ms']}" f"{'+' if r['pilot']['ms']-r['vanilla']['ms']>=0 else ''}{r['pilot']['ms']-r['vanilla']['ms']}" - f"{'✓' if r['vanilla']['code']==r['pilot']['code']==0 else '⚠'}" + f"{'✓' if r['vanilla']['code']==r['pilot']['code'] else '⚠'}" for r in rows) out = f""" @@ -146,7 +183,8 @@ def block(d):

Vanilla vs Pilot — A/B report

-

App {esc(a.app)} · delivered from the Pilot R2 artifact registry · generated by scripts/ab_report.py

+

App {esc(a.app)} · mode {esc(a.mode)} · commands: {esc(src)} · generated by scripts/ab_report.py

+
CI note: GitHub-hosted runners have no nested virtualization (KVM), so VM-launching +commands cannot run there. This report exercises non-VM commands (version, help, subcommand help) +that prove the adapter forwards the full CLI surface identically. Run microVM workloads locally with +--mode pilotctl against a daemon.

Summary

-{summary}
CommandVanilla (ms)Pilot (ms)Δ overheadMatch
+{summary}
CommandVanilla (ms)Pilot (ms)Δ overheadExit match

Adapter-generated help — {esc(a.ns)}.help (local, no backend), {hms} ms

Pilot · {esc(a.ns)}.help (generated by the adapter)
{esc(help_doc.rstrip())}
-
Vanilla · smolvm --help ({vhelp_ms} ms)
{esc(vhelp_out.rstrip())}
+
Vanilla · {esc(vcli)} --help ({vhelp_ms} ms)
{esc(vhelp_out.rstrip())}

Per-command detail

diff --git a/submissions/io.pilot.smolvm/ab-commands.json b/submissions/io.pilot.smolvm/ab-commands.json new file mode 100644 index 0000000..817f94c --- /dev/null +++ b/submissions/io.pilot.smolvm/ab-commands.json @@ -0,0 +1,17 @@ +{ + "_comment": "CI-safe A/B command set for io.pilot.smolvm. NO microVM boots — GitHub runners have no KVM. These prove the adapter forwards the full smolvm command tree (machine/pack/serve/config) identically to the vanilla binary. Run VM workloads locally with --mode pilotctl.", + "commands": [ + {"label": "Version", "note": "enumerated method → `smolvm --version`", + "vanilla": ["--version"], "method": "smolvm.version", "payload": {}}, + {"label": "Top-level help", "note": "passthrough → `smolvm --help`", + "vanilla": ["--help"], "method": "smolvm.exec", "payload": {"args": ["--help"]}}, + {"label": "machine --help", "note": "subcommand group reachable via passthrough", + "vanilla": ["machine", "--help"], "method": "smolvm.exec", "payload": {"args": ["machine", "--help"]}}, + {"label": "pack --help", "note": "subcommand group reachable via passthrough", + "vanilla": ["pack", "--help"], "method": "smolvm.exec", "payload": {"args": ["pack", "--help"]}}, + {"label": "serve --help", "note": "subcommand group reachable via passthrough", + "vanilla": ["serve", "--help"], "method": "smolvm.exec", "payload": {"args": ["serve", "--help"]}}, + {"label": "config --help", "note": "subcommand group reachable via passthrough", + "vanilla": ["config", "--help"], "method": "smolvm.exec", "payload": {"args": ["config", "--help"]}} + ] +}