Skip to content

Commit c657c0c

Browse files
diag(probe): reveal the masked CI cascade + two governance-bridge hypotheses
- The `dune runtest` repair landed (build/coverage both green on the parts they can reach); that unmasked `Run codegen WASM tests`, which had been skipped on every run since 2026-09-21. The probe now runs the whole remaining chain (codegen WASM, Bun-ESM, native Bun, face transformers, no-extension-ts) in one cycle so the rest of the cascade is visible without one failure per push. - Two throwaway workflows test why `Governance Baseline` cannot start: A grants job-level permissions to the same local reusable, B drops the reusable entirely. Whichever starts tells us the axis. - examples README for the on-ramp consumer. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
1 parent 14efe79 commit c657c0c

4 files changed

Lines changed: 165 additions & 0 deletions

File tree

‎.github/workflows/zz-probe-a.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# This workflow is managed by gh actions-lock.
2+
# SPDX-License-Identifier: MPL-2.0
3+
#
4+
# TEMPORARY PROBE (deleted before merge) — hypothesis A:
5+
# the local reusable call fails at run-creation because the CALLER JOB does
6+
# not grant permissions explicitly (the only reusable caller in this repo that
7+
# works, spark-theatre-gate.yml, does grant them at job level). Same shape as
8+
# governance-baseline.yml, plus job-level permissions.
9+
name: ZZ Probe A
10+
on:
11+
pull_request:
12+
permissions:
13+
contents: read
14+
jobs:
15+
governance:
16+
uses: ./.github/workflows/governance-baseline-impl.yml
17+
permissions:
18+
contents: read

‎.github/workflows/zz-probe-b.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# This workflow is managed by gh actions-lock.
2+
# SPDX-License-Identifier: MPL-2.0
3+
#
4+
# TEMPORARY PROBE (deleted before merge) — hypothesis B: no reusable workflow
5+
# at all, and the pinned context reproduced by naming the job literally.
6+
# A normal (non-caller) workflow always starts, so this is what the bridge
7+
# becomes if the reusable mechanism itself is what the platform refuses.
8+
name: ZZ Probe B
9+
on:
10+
pull_request:
11+
permissions:
12+
contents: read
13+
jobs:
14+
governance:
15+
name: Validate Hypatia baseline
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 5
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v7.0.1
21+
- name: Validate .hypatia-baseline.json (if present)
22+
run: |
23+
echo "probe B: plain job, no reusable workflow"
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// SPDX-License-Identifier: CC-BY-SA-4.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
= Consumer example: an extension-shaped host boundary
4+
:toc: macro
5+
:icons: font
6+
7+
A complete, minimal consumer of AffineScript — the thing
8+
link:../../../docs/ON-RAMP.adoc[docs/ON-RAMP.adoc] describes in prose.
9+
10+
It is shaped after the first real consumer request
11+
(`hyperpolymath/affinescript#771`, from `hyperpolymath/blocky-writer#73`): a
12+
browser extension whose document engine lives behind a two-function boundary,
13+
compiled to WebAssembly, with an error taxonomy that has to cross that
14+
boundary intact.
15+
16+
toc::[]
17+
18+
== Run it
19+
20+
[source,console]
21+
----
22+
$ just on-ramp-example # from the repository root
23+
# or
24+
$ ./examples/consumers/extension-boundary/build.sh
25+
----
26+
27+
Expected output:
28+
29+
[source,console]
30+
----
31+
Compiling boundary.affine -> boundary.wasm
32+
Compiled …/src/boundary.affine -> …/dist/boundary.wasm (WASM)
33+
Running host.mjs
34+
examples/consumers/extension-boundary: OK
35+
----
36+
37+
`build.sh` finds the compiler three ways, so it works in a checkout
38+
(`_build/default/bin/main.exe`), against a release binary on `PATH`
39+
(`affinescript`), or with `dune exec`. That is deliberate: a consumer's build
40+
script should not care which of the three you have.
41+
42+
== The four files, and why each exists
43+
44+
[cols="1,3",options="header"]
45+
|===
46+
| File | What it is
47+
48+
| `src/boundary.affine`
49+
| The consumer's own module. It declares the host functions it calls with
50+
`extern fn` (the whole binding story — no compiler change, no IDL, no
51+
adapter package), wraps them in typed helpers, and never touches a string
52+
it did not receive from the host.
53+
54+
| `host.mjs`
55+
| The other half: an import object that satisfies those declarations, a
56+
`{code, message, context}` payload the host owns, and assertions for the
57+
contract (success path, failure path, message round-trip, exported
58+
surface).
59+
60+
| `build.sh`
61+
| Compile, then run. The "smallest thing that turns `src/*.affine` into
62+
something that compiles", in executable form.
63+
64+
| `dist/`
65+
| Build output (gitignored). `boundary.wasm` is what you would ship in an
66+
extension bundle next to `host.mjs`'s import object.
67+
|===
68+
69+
== The parts worth copying
70+
71+
*Declare the boundary before porting bodies.* The `extern fn` block at the top
72+
of `src/boundary.affine` fixes every type the guest can see. In a real port
73+
this is the first thing to write, because the compiler's error messages are
74+
most useful at the seam.
75+
76+
*Codes, not strings.* `bw_detect_blocks` returns an `Int` status; the message
77+
lives host-side and is reachable byte-by-byte via
78+
`bw_error_message_len` / `bw_error_message_byte`. The guest never allocates,
79+
never encodes, and cannot invent an error shape the host does not know.
80+
81+
*Assert the contract in CI.* `host.mjs` is not a smoke test — it asserts that
82+
`fill` propagates the exact `BW_*` code, that the message round-trips
83+
byte-for-byte, and that the guest exports the names it advertised. The same
84+
script runs in this repository's `build` job, so the on-ramp cannot rot.
85+
86+
*Nothing here is browser-specific.* Node is used only to read the file and to
87+
have an assertion library. `WebAssembly.instantiate(bytes, imports)` with the
88+
same `imports` object runs unchanged in a Firefox/Chrome extension background
89+
frame, in Bun, or in Deno.
90+
91+
== What this example deliberately does not do
92+
93+
* *No byte transfer through linear memory.* Parameters are `i32`, so real byte
94+
work is a `(pointer, length)` pair into the guest's exported `memory`; see
95+
`tests/codegen/test_net_send.mjs` for a worked host-side write, and
96+
`tests/codegen/test_file_roundtrip.mjs` for a round-trip. Kept out here so
97+
the boundary pattern is the only thing in view.
98+
* *No closures.* Passing a callback to the host uses the
99+
`[fnId @+0, envPtr @+4]` + `__indirect_function_table` ABI; a complete
100+
dispatcher is in `tests/codegen/test_closure_indirect_dispatch.mjs` and in
101+
`packages/affine-vscode/mod.js`.
102+
* *No bundler, no manifest.* There is no `deno.json`, no `package.json`, no
103+
`affine.toml`. A consumer's build file is this shell script, because the
104+
compiler takes a source file and `-o`.

‎tools/ci/diag-probe.sh‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ if log.exists():
4646
else:
4747
annotate("diag-runtest", "runtest.log was not produced")
4848
49+
# ── 1b. the whole masked cascade, in one shot ─────────────────────────────
50+
# `dune runtest` failed first, so every later step in the build job was
51+
# skipped and its state was unknown. Now that the tests pass, surface the
52+
# whole remaining chain at once instead of one failure per CI cycle.
53+
CASCADE = [
54+
("codegen WASM", ["bash", "tools/run_codegen_wasm_tests.sh"]),
55+
("codegen Bun-ESM (codegen-deno corpus)", ["bash", "tools/run_codegen_deno_tests.sh"]),
56+
("native Bun-ESM", ["bash", "tools/run_codegen_bun_tests.sh"]),
57+
("face transformers", ["bash", "tools/run_face_transformer_tests.sh"]),
58+
("no-extension-ts", ["bash", "tools/check-no-extension-ts.sh"]),
59+
]
60+
out = []
61+
for label, argv in CASCADE:
62+
rc, text = run(argv, timeout=900)
63+
tail = text.splitlines()[-40:]
64+
out.append(f"===== {label}: rc={rc} =====\n" + "\n".join(tail))
65+
body = "\n\n".join(out)
66+
annotate("diag-cascade", body)
67+
summary("diag: masked cascade", body)
68+
4969
# ── 2. parser probe: which construct does the #644 test need? ─────────────
5070
VARIANTS = {
5171
"v1-exact-test-source": """module EmptyArm;

0 commit comments

Comments
 (0)