feat(lark): 支持配置实时卡片按钮 (#1323) #4076
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - master | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Compile + workflow-core only. The unit suite used to live here and dominated | |
| # the wall (~10.5 min of a 12 min job on a 4-core runner). Tests now run as | |
| # three independent shards; this job stays a compile gate. | |
| # | |
| # Ruleset "Require CI green on master" still lists only `build`. The suite | |
| # now reports as the aggregator job named `test` (not `test (1/3)` — those | |
| # names change with the shard count). Adding `test` to that ruleset is a | |
| # repo-settings change, not a workflow step. Until an admin does that, a | |
| # red shard does not block merge. | |
| build: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| # Bun is the package manager (packageManager: bun@1.4.2). node-pty's native | |
| # build runs only because it is in `trustedDependencies` — bun skips | |
| # dependency lifecycle scripts otherwise, and the sandbox/PTY tests below | |
| # need a real build/Release/pty.node. | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.2 | |
| - run: bun install --frozen-lockfile | |
| - run: bun run build | |
| - run: bun run workflow-core:test | |
| # Same unit project as `bun run test`, split across runners with vitest | |
| # `--shard=i/N`. Each shard still file-parallel internally (forks). They do | |
| # NOT `needs: build` — waiting on tsc would serialize the start; a few files | |
| # spawn `dist/cli.js`, so each shard rebuilds (~45s) and starts immediately. | |
| # fail-fast is off so a red shard still lets the others report their wall. | |
| test: | |
| name: test (${{ matrix.shard }}/3) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3] | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| # dsh 沙盒集成用例要在 runner 上真跑 bwrap:ubuntu-24.04 默认 | |
| # kernel.apparmor_restrict_unprivileged_userns=1 且 bubblewrap 包不带 | |
| # AppArmor profile,bwrap 一起 userns 就秒退("setting up uid map: | |
| # Permission denied",exit 1)。这里放开限制并预装 bubblewrap,让沙盒 | |
| # 用例在 CI 上真正执行。整步 best-effort:哪一步失败都不阻塞——测试侧 | |
| # 有 bwrap 运行时探测 gate,跑不了会 loud skip 而不是超时挂掉。 | |
| - name: Enable bwrap sandbox for integration tests (best-effort) | |
| run: | | |
| sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true | |
| command -v bwrap >/dev/null || sudo apt-get install -y bubblewrap || true | |
| if bwrap --bind / / --unshare-user -- /bin/true 2>/dev/null; then | |
| echo "bwrap userns OK — sandbox integration tests will run for real" | |
| else | |
| echo "bwrap still unavailable — sandbox integration tests will skip (see test-side gate)" | |
| fi | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.2 | |
| - run: bun install --frozen-lockfile | |
| - run: bun run build | |
| # Wrapper retries once only when every file already passed and vitest's | |
| # fork pool then reports "Worker exited unexpectedly" (teardown flake; | |
| # measured on this PR's first shard-3 run). A real failed file is not retried. | |
| - name: Unit tests (shard ${{ matrix.shard }}/3) | |
| run: node scripts/run-unit-shard.mjs ${{ matrix.shard }}/3 | |
| # One stable check name for the ruleset. Matrix names (`test (1/3)` …) | |
| # must not be required — they change if the shard count changes. This | |
| # job is AND-of-shards (`if: always()` + fail on any non-success). | |
| # Ruleset "Require CI green on master" must list context `test`. | |
| test-ok: | |
| name: test | |
| needs: test | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Require every shard | |
| run: | | |
| case "${{ needs.test.result }}" in | |
| success) exit 0 ;; | |
| *) echo "shards: ${{ needs.test.result }}"; exit 1 ;; | |
| esac | |
| # Second runner leg: the SAME unit files executed on Bun instead of Node. | |
| # | |
| # WHY THIS IS NOT REDUNDANT WITH THE `test` SHARDS ABOVE: vitest forks Node | |
| # workers even when launched through bun (measured — `bun x vitest` still | |
| # reports process.versions.node), so every test BODY in the jobs above runs on | |
| # Node. Bun-specific behaviour is therefore structurally invisible there: its | |
| # `fetch` error taxonomy (which cannot distinguish a sent from an unsent | |
| # request), its startup-frozen `os.homedir()`, `Bun.file`, compiled-binary | |
| # paths. Bun is what ships the single-file executable, so those paths need a | |
| # leg that actually runs on it. | |
| # | |
| # Non-blocking on purpose (for now): the shim in test/bun-test-shim.ts | |
| # deliberately does not fake `vi.doMock`/`resetModules`/`importOriginal`, and | |
| # scripts/run-bun-tests.mjs excludes the files needing them, but the remaining | |
| # population has not yet had a full green baseline established across a CI | |
| # runner's environment. Flip `continue-on-error` off once it holds green — a | |
| # gate that is red for environmental reasons trains people to ignore it. | |
| # | |
| # The generous timeout is inherent to the design: the runner spawns ONE bun | |
| # process PER FILE, because `bun test` otherwise runs every file in a single | |
| # process and cross-file interference cascades (measured: 933 failures batched | |
| # vs ~2% one-per-process). Startup cost therefore dominates the wall clock. | |
| bun-test: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.2 | |
| - run: bun install --frozen-lockfile | |
| - run: bun run build | |
| # Exercise the runner itself before the 20-minute suite. `node --check` cannot | |
| # catch a missing binding (a ReferenceError is a RUNTIME error, verified), so a | |
| # commit that deleted two constants still printed `994 files` and then ran ZERO | |
| # — the whole leg's cost with none of its value. This runs one file in ~6s and | |
| # also asserts that no `bun` spawn escaped the home fence. | |
| # | |
| # NOT under continue-on-error semantics by accident: the job as a whole is | |
| # advisory for now, but if this step fails the suite result below is meaningless, | |
| # so it runs first and its failure is the one worth reading. | |
| - run: bun run test:bun:self-check | |
| - run: bun run test:bun | |
| # The Bun single-file executable is a SHIPPED artifact (attached to every | |
| # GitHub Release), but until this job existed nothing built or ran it before a | |
| # tag push — so `bun build --compile` breakage was only discovered at release | |
| # time, after npm had already published. Two real defects reached that far: | |
| # • the compiled dashboard crashlooped on a deep `require()` the bundler | |
| # never embedded (qrcode-terminal/vendor), and | |
| # • the dashboard had no supervisor launcher at all. | |
| # Neither is visible to `bun run test` (vitest runs the Node path) nor to a | |
| # `capabilities --json` canary. This job compiles the host-arch binary and runs | |
| # scripts/smoke-bun-binary.mjs, which exercises the layers that actually carry | |
| # Bun-migration risk: the hidden self-spawn entries, the supervised dashboard | |
| # member booting with its embedded modules, and its HTTP listener. | |
| # | |
| # Kept a separate job (not steps appended to `build`) so a Bun-only failure is | |
| # legible at a glance and does not mask unit-test results. | |
| bun-binary: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Build and smoke on the supported glibc 2.28 floor | |
| # node-pty is compiled on the build host. ubuntu-latest raised the embedded | |
| # native to GLIBC_2.34, even though Node 22 and Bun still run on 2.28. | |
| # Build inside manylinux_2_28 and execute the full smoke there so the PR | |
| # gate is byte-for-byte the same compatibility boundary as release. | |
| run: | | |
| scripts/build-linux-glibc-baseline.sh \ | |
| bun-linux-x64 dist-bin/botmux-linux-x64 \ | |
| "0.0.0-ci.${{ github.run_number }}" | |
| # The musl (Alpine) binary, gated on PRs — not only at release time. | |
| # | |
| # WHY THIS JOB EXISTS SEPARATELY FROM `bun-binary`: the musl binary is a shipped | |
| # artifact with a DIFFERENT native. `pty.node` is embedded at compile time and | |
| # node-pty ships no linux prebuild, so it is compiled against whatever libc the | |
| # builder runs on (measured: glibc host → `NEEDED libc.so.6`; node:22-alpine → | |
| # `NEEDED libc.musl-x86_64.so.1`). A glibc native inside a musl binary builds | |
| # cleanly and fails only when the native is dlopen'd, on the user's machine. | |
| # | |
| # This job previously lived ONLY in release.yml, which runs on tag push. That | |
| # meant a PR could change the build script, the embed plugin, or node-pty's | |
| # version and the musl leg was first exercised during a release — after npm had | |
| # published. This mirrors the release leg so the PR gate is not weaker. | |
| # | |
| # Only linux-x64-musl is built: GitHub offers no Alpine runner, so musl requires | |
| # a container, and arm64 would need the (slower, separately-queued) arm64 runner. | |
| # The release workflow still builds BOTH musl arches; this is the PR canary. | |
| # | |
| # ⚠️ THE COST OF THAT x64-ONLY CHOICE, MEASURED: it was made for runner time, and | |
| # arm64-musl turned out to be the one cell with a PLATFORM-level obstacle — a | |
| # job-level `container: node:22-alpine` cannot run JS actions (actions/checkout) | |
| # on an arm64 runner at all. So this gate was green while the release failed. The | |
| # structure below (JS actions on the host, build inside `docker run`) is the fix, | |
| # and it is used here too so both workflows stay on ONE shape: if this leg ever | |
| # gains an arch, it will not re-learn the same lesson. | |
| bun-binary-musl: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| steps: | |
| # Runs on the HOST (glibc), which is what keeps this arch-portable. | |
| - uses: actions/checkout@v6 | |
| - name: Build and smoke the musl binary inside Alpine | |
| # Every musl-side step in one container invocation. `--platform` is | |
| # deliberately NOT passed: the image must resolve to the runner's own | |
| # architecture, since the native has to be built for the arch it ships on. | |
| run: | | |
| docker run --rm \ | |
| -v "$PWD:/work" -w /work \ | |
| -e CI_RUN_NUMBER="${{ github.run_number }}" \ | |
| -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ | |
| node:22-alpine sh -euc ' | |
| # python3/make/g++ are node-gyp'"'"'s toolchain: node-pty has no linux | |
| # prebuild and must compile here. binutils supplies readelf. | |
| apk add --no-cache python3 make g++ libstdc++ binutils >/dev/null | |
| npm i -g bun@1.4.2 --loglevel=error | |
| echo "bun $(bun --version) on $(uname -m)" | |
| # Compiles node-pty against MUSL — the entire point of this job. | |
| bun install --frozen-lockfile | |
| # Fail closed rather than compiling a musl binary around a glibc | |
| # native: that mistake survives the build and surfaces only when the | |
| # native is loaded. | |
| f=node_modules/node-pty/build/Release/pty.node | |
| test -f "$f" || { echo "node-pty native missing at $f"; exit 1; } | |
| if ! readelf -d "$f" | grep -q "libc\.musl"; then | |
| echo "REFUSING: $f is not musl-linked:"; readelf -d "$f" | grep NEEDED; exit 1 | |
| fi | |
| echo "ok: $(readelf -d "$f" | grep NEEDED | tr -s " ")" | |
| # Same reason and same ORDER as the glibc job above: the compiled | |
| # binary has no package.json on disk, so the version is baked at | |
| # compile time, and the repo'"'"'s 0.0.0 placeholder is deliberately | |
| # treated as "nothing baked" — compiling from it yields | |
| # `--version: unknown`, which the smoke gate fails. Must also come | |
| # AFTER install, so --frozen-lockfile sees the committed manifest. | |
| npm version "0.0.0-ci.${CI_RUN_NUMBER}" --no-git-tag-version --allow-same-version | |
| bun run build | |
| bun scripts/build-bun-binary.mjs --target bun-linux-x64-musl \ | |
| --out dist-bin/botmux-linux-x64-musl | |
| # Same shared script as the glibc leg, so this gate can never be the | |
| # weaker one. This is also where an unloadable native is caught: | |
| # dist/cli.js statically imports node-pty (via the backends), and | |
| # node-pty dlopens the native at MODULE scope — verified by mutation, | |
| # corrupting the embedded pty.node makes check 1 die with | |
| # ERR_DLOPEN_FAILED. So running the binary at all proves the musl | |
| # native loads; no separate PTY step is needed. | |
| node scripts/smoke-bun-binary.mjs dist-bin/botmux-linux-x64-musl | |
| # The legacy-pm2 reaper identifies processes and sockets before it | |
| # signals or deletes anything, and BusyBox answers those questions | |
| # DIFFERENTLY than procps/lsof: `ps -p` is rejected outright, and | |
| # `lsof <path>` ignores the path and exits 0. Both differences | |
| # silently DISABLED a guard — the reap found nothing to stop, and an | |
| # orphaned rpc.sock read as a live God so the "legacy pm2 still | |
| # running" warning could never clear. | |
| # | |
| # `bun run test` cannot see any of this: vitest runs on the glibc | |
| # host, where reverting either probe leaves the suite fully green | |
| # (MEASURED). This is the only gate that exercises the BusyBox shapes, | |
| # which is why it runs here rather than as a unit test. Verified to | |
| # have teeth: reverting either probe makes it exit 1 (1 and 8 failed | |
| # checks respectively). | |
| node scripts/smoke-musl-reaper-probes.mjs dist/core/legacy-pm2-reaper.js | |
| # Hand dist-bin back to the HOST user, exactly as the release leg does. | |
| # | |
| # This job needs it less (nothing after this step touches dist-bin), and | |
| # that asymmetry is precisely how the bug shipped: the release leg has a | |
| # `Checksums` step that WRITES into dist-bin, and root-owned files made | |
| # it fail with `Permission denied` after both musl builds had succeeded. | |
| # Keeping the two legs isomorphic means the PR gate exercises the same | |
| # ownership handoff the release depends on. | |
| chown -R "$HOST_UID:$HOST_GID" dist-bin | |
| ' | |
| - name: Prove the host can WRITE into dist-bin (canary.5's actual failure) | |
| # Not cosmetic — this is the only place CI can catch the ownership bug. | |
| # | |
| # v3.18.0-canary.5 failed AFTER both musl builds and all smoke checks passed: | |
| # the release leg's `Checksums` step writes .sha256 files INTO dist-bin, and | |
| # the container had left it owned by root, so the runner's non-root user got | |
| # `Permission denied`. This PR job has no such step, so without the check | |
| # below it would stay green while the release breaks — exactly the asymmetry | |
| # that let the bug ship. | |
| # | |
| # Deliberately mirrors what `Checksums` does (create a file in the directory) | |
| # rather than merely reading a file: read access was never the problem. | |
| run: | | |
| echo "dist-bin owner: $(stat -c '%u:%g' dist-bin) (runner is $(id -u):$(id -g))" | |
| cd dist-bin | |
| for f in botmux-*; do sha256sum "$f" > "$f.sha256"; done | |
| ls -la | |
| echo "✅ host user wrote checksums into a container-produced directory" | |
| # The darwin binaries are shipped artifacts that NO pre-release gate touched | |
| # until this job existed: `darwin`/`macos` appeared zero times in this file, so | |
| # every macOS-only failure mode was first observed during a release — after the | |
| # tag had been pushed. Two reached that point: | |
| # • 3.18.14's darwin-arm64 shipped an INVALID ad-hoc Mach-O signature (bun | |
| # 1.4.0, oven-sh/bun#39764). Recent macOS SIGKILLs such a binary before | |
| # main(), so all `botmux upgrade` saw was a dead process (exit 137). | |
| # • v3.19.0 was stopped by release.yml's codesign gate on darwin-x64: bun | |
| # 1.4.1 fixed the HOST arch only, and the cross-compiled x64 output still | |
| # left ~25 MB outside its signed range (CodeDirectory codeLimit 68776432 | |
| # vs signature start 94548464). The tag is now permanently burned with no | |
| # Release behind it, and the fix had to ship as v3.19.1. | |
| # Neither was visible to any other leg: vitest runs on Linux, and the | |
| # linux/musl binary jobs cannot invoke `codesign` because it is an Apple tool | |
| # that does not exist there. The signature path was reachable ONLY in a | |
| # release run, which is precisely why it kept escaping to users. | |
| # | |
| # WHY macos-14 SPECIFICALLY, and not "some macOS runner": it is arm64, which | |
| # is what makes darwin-x64 a CROSS-COMPILE — and cross-compiling is the only | |
| # configuration that reproduces the v3.19.0 defect at all (the native arm64 | |
| # output was valid on the very same run). On an x64 macOS runner the broken | |
| # output would be darwin-arm64 instead; the loop below verifies BOTH, so the | |
| # gate stays correct either way, but the runner must track release.yml's or | |
| # this stops exercising the cell the release actually ships from. | |
| # | |
| # Free for this repo: macOS runners bill 10× minutes on private repos, but | |
| # public repos consume no included minutes at all. | |
| bun-binary-darwin: | |
| runs-on: macos-14 | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.2 | |
| # node-pty SHIPS darwin prebuilds (prebuilds/darwin-{x64,arm64}/pty.node | |
| # plus the spawn-helper sidecar), which is why ONE macOS runner can emit | |
| # both arches — unlike linux, where the native is node-gyp'd for the host | |
| # arch only. This install is also the only place in CI where the darwin | |
| # branch of scripts/build-bun-binary.mjs (`resolveNodePtyNative` picking a | |
| # prebuild, and the spawn-helper embed) is exercised at all. | |
| - run: bun install --frozen-lockfile | |
| # Same reason and same ORDER as the linux/musl legs: the compiled binary | |
| # has no package.json on disk, so the version is baked at compile time, | |
| # and the repo's 0.0.0 placeholder is deliberately read as "nothing baked" | |
| # — compiling from it yields `--version: unknown`, which the smoke gate | |
| # fails. Must come AFTER install so --frozen-lockfile sees the committed | |
| # manifest, and BEFORE the compile that bakes the value in. | |
| - run: npm version "0.0.0-ci.${{ github.run_number }}" --no-git-tag-version --allow-same-version | |
| - run: bun run build | |
| - name: Compile both darwin binaries | |
| # BOTH arches, because the shipped set is both and the defect lives in | |
| # the cross-built one. Mirrors release.yml's `bun-binaries` macOS leg | |
| # (same script, same --out naming) so this gate cannot drift into | |
| # testing a different artifact than the one that ships. | |
| run: | | |
| mkdir -p dist-bin | |
| for t in bun-darwin-x64 bun-darwin-arm64; do | |
| bun scripts/build-bun-binary.mjs --target "$t" --out "dist-bin/botmux-${t#bun-}" | |
| done | |
| - name: Prove each binary really is the arch it is named after | |
| # Without this the signature verdict below can be VACUOUS. If `--target` | |
| # were ignored, or the native resolution fell back to the host prebuild, | |
| # both outputs would be native arm64 — `codesign` would validate them | |
| # happily (native signing was never the broken case) and this job would | |
| # be green while darwin-x64 users received an arm64 binary that cannot | |
| # execute. The defect this job exists for lives ONLY in the cross-built | |
| # output, so "the cross-built output is genuinely cross-built" is | |
| # load-bearing, not decoration. | |
| # | |
| # Also fails closed when only ONE of the two was produced: the verify | |
| # loop's own `found=0` guard cannot see a half-empty dist-bin. | |
| run: | | |
| set -e | |
| for pair in "x64:x86_64" "arm64:arm64"; do | |
| name="${pair%%:*}"; want="${pair##*:}" | |
| f="dist-bin/botmux-darwin-$name" | |
| test -f "$f" || { echo "REFUSING: $f was not produced"; exit 1; } | |
| # Whitespace-stripped rather than compared raw: `lipo -archs` prints | |
| # arch names space-separated, so this also rejects a FAT binary that | |
| # merely contains the wanted arch (we ship thin, one arch per file). | |
| got="$(lipo -archs "$f" | tr -d '[:space:]')" | |
| echo "$f: lipo -archs → $got (want $want)" | |
| [ "$got" = "$want" ] || { echo "REFUSING: $f is $got, not $want"; exit 1; } | |
| done | |
| - name: Verify Mach-O signatures on every darwin binary | |
| # The gate this whole job exists for. Byte-for-byte the same check | |
| # release.yml runs after its darwin compile, so the PR gate can never be | |
| # the weaker one — including the `*.map|*.sha256` skip (`sourcemap: | |
| # 'linked'` drops a .map next to each binary, and codesign would reject | |
| # it as "not a Mach-O file") and the fail-closed `found=0`, which stops a | |
| # glob that matches nothing from reading as a pass. | |
| # | |
| # `--strict` is the parameter that matters: it is what newer macOS | |
| # loaders effectively enforce, and it is what separates "rejects | |
| # v3.19.0's darwin-x64" from "lets it through". | |
| run: | | |
| shopt -s nullglob | |
| found=0 | |
| for f in dist-bin/botmux-darwin-*; do | |
| case "$f" in *.map|*.sha256) continue;; esac | |
| found=1 | |
| echo "verifying signature: $f" | |
| codesign --verify --strict --verbose=2 "$f" | |
| done | |
| if [ "$found" = 0 ]; then echo "no darwin binaries in dist-bin"; exit 1; fi | |
| - name: Smoke-test the host-arch binary | |
| # A runner can only execute its own arch, so this runs the arm64 output; | |
| # the cross-built x64 one is covered by the signature + arch checks | |
| # above. Shares scripts/smoke-bun-binary.mjs with the release leg and the | |
| # linux/musl jobs, so no platform gets a weaker smoke — and on darwin | |
| # that script ALSO re-verifies the signature as its check 0, before | |
| # anything else runs. | |
| # | |
| # What only this platform can prove: the embedded darwin native actually | |
| # loads. node-pty dlopens pty.node at module scope and needs its | |
| # spawn-helper sidecar on macOS (linux uses forkpty() and has none), so | |
| # a mis-embedded darwin native surfaces here and nowhere else. | |
| run: | | |
| BIN="dist-bin/botmux-darwin-$(node -e 'process.stdout.write(process.arch)')" | |
| test -x "$BIN" || { echo "host-arch binary $BIN not built on this runner"; exit 1; } | |
| node scripts/smoke-bun-binary.mjs "$BIN" |