diff --git a/.github/actions/install-bench-tools/action.yml b/.github/actions/install-bench-tools/action.yml new file mode 100644 index 0000000000..5a1b129c35 --- /dev/null +++ b/.github/actions/install-bench-tools/action.yml @@ -0,0 +1,23 @@ +name: install-bench-tools +description: Install hyperfine + GNU time on the bench runner. macOS /usr/bin/time isn't GNU; install gnu-time and shadow `time` via $HOME/bin so bench scripts can use `time -v` regardless of platform. + +inputs: + platform: + description: linux or mac + required: true + +runs: + using: composite + steps: + - name: Install bench tools (linux) + if: inputs.platform == 'linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y hyperfine time + - name: Install bench tools (mac) + if: inputs.platform == 'mac' + shell: bash + run: | + brew install hyperfine gnu-time + mkdir -p "$HOME/bin" + ln -sf "$(brew --prefix gnu-time)/bin/gtime" "$HOME/bin/time" + echo "$HOME/bin" >> $GITHUB_PATH diff --git a/.github/actions/install-utoo-npm-baseline/action.yml b/.github/actions/install-utoo-npm-baseline/action.yml new file mode 100644 index 0000000000..6f5a1738a9 --- /dev/null +++ b/.github/actions/install-utoo-npm-baseline/action.yml @@ -0,0 +1,16 @@ +name: install-utoo-npm-baseline +description: Install latest published `utoo` from npm into a temp directory and set $UTOO_NPM_BIN. Used by bench-phases jobs to add a published-version baseline column alongside the current-branch utoo. Installed locally (not -g) so it doesn't shadow $GITHUB_WORKSPACE/dist/utoo on PATH; npm's node_modules/.bin/ symlink isn't always populated for local installs, so we point at node_modules/utoo/bin/utoo (the package's `bin` field) directly. + +runs: + using: composite + steps: + - name: Install utoo@npm baseline + shell: bash + run: | + mkdir -p /tmp/utoo-npm-pkg && cd /tmp/utoo-npm-pkg + npm init -y >/dev/null + npm install utoo --no-audit --no-fund --no-progress + UTOO_NPM_BIN="$PWD/node_modules/utoo/bin/utoo" + chmod +x "$UTOO_NPM_BIN" + "$UTOO_NPM_BIN" --version + echo "UTOO_NPM_BIN=$UTOO_NPM_BIN" >> $GITHUB_ENV diff --git a/.github/actions/post-bench-phases-comment/action.yml b/.github/actions/post-bench-phases-comment/action.yml new file mode 100644 index 0000000000..a4d9e5d3c9 --- /dev/null +++ b/.github/actions/post-bench-phases-comment/action.yml @@ -0,0 +1,48 @@ +name: post-bench-phases-comment +description: Render bench-phases output as markdown tables and upsert a sticky PR comment. The comment is keyed by header `pm-bench-phases-` so each platform's results live as a separate sticky comment, idempotent across commits. + +inputs: + platform: + description: Platform name (linux / mac) — used in the comment header marker + required: true + os: + description: Runner OS string (ubuntu-latest / macos-latest) — shown in the comment title + required: true + github-token: + description: GitHub token for `gh` CLI lookups + sticky-comment posting + required: true + +runs: + using: composite + steps: + - name: Build PR comment body + shell: bash + run: bash .github/scripts/render-bench-phases-comment.sh ${{ inputs.platform }} ${{ inputs.os }} + - name: Find Current PR + id: findPr + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + GH_REPO: ${{ github.repository }} + run: | + PR_NUMBER="" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + else + PR_NUMBER=$(gh pr list --head "${{ github.ref_name }}" --state open --json number --jq '.[0].number' || true) + if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" == "null" ]; then + PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state open --json number --jq '.[0].number' || true) + fi + fi + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT + fi + - name: Post PR Comment + if: steps.findPr.outputs.number + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pm-bench-phases-${{ inputs.platform }} + path: /tmp/pm-bench-output/pr_comment.md + number: ${{ steps.findPr.outputs.number }} + recreate: true + GITHUB_TOKEN: ${{ inputs.github-token }} diff --git a/.github/actions/setup-utoo-bin/action.yml b/.github/actions/setup-utoo-bin/action.yml new file mode 100644 index 0000000000..a59fe6454b --- /dev/null +++ b/.github/actions/setup-utoo-bin/action.yml @@ -0,0 +1,28 @@ +name: setup-utoo-bin +description: Download a utoo artifact built by pm-e2e-bench's build-* job, install it on PATH, optionally create the `ut` symlink. Unix only — Windows e2e handles its own .exe layout inline. + +inputs: + artifact: + description: Artifact name to download (e.g. utoo-linux-x64, utoo-mac-arm64) + required: true + with-ut-symlink: + description: Create `ut → utoo` symlink alongside the binary (e2e tests use both names; bench scripts don't). + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Download utoo binary + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: dist + - name: Install utoo + shell: bash + run: | + chmod +x dist/utoo + if [ "${{ inputs.with-ut-symlink }}" = "true" ]; then + ln -sf utoo dist/ut + fi + echo "$GITHUB_WORKSPACE/dist" >> $GITHUB_PATH diff --git a/.github/scripts/render-bench-phases-comment.sh b/.github/scripts/render-bench-phases-comment.sh new file mode 100755 index 0000000000..7cc2340dad --- /dev/null +++ b/.github/scripts/render-bench-phases-comment.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Render the pm-bench-phases sticky PR comment from per-registry log files +# in /tmp/pm-bench-output/. Called by pm-e2e-bench.yml's bench-phases-* jobs. +# +# Output: /tmp/pm-bench-output/pr_comment.md +# +# Args: +# $1 PLATFORM e.g. linux, mac +# $2 OS e.g. ubuntu-latest, macos-latest +# +# Each phase block in the bench output looks like: +# +# ## p0_full_cold +# PM wall ±σ user sys RSS pgMinor +# utoo 5.78s 0.12s 2.34s 1.23s 456M 12.3K +# bun 4.32s 0.08s 1.98s 0.87s 234M 8.5K +# PM vCtx iCtx net RX net TX cache node_mod lock +# utoo 45 12 234M 12K 450M 400M 3M +# bun 38 8 220M 11K 220M 400M 2M +# +# We emit one markdown subsection per phase containing two tables (timing +# and resource), one row per PM. Sticky-PR-comment dedup is upstream of +# this script — we just produce the body. +set -eu + +PLATFORM="$1" +OS="$2" +OUT=/tmp/pm-bench-output/pr_comment.md +SHA="${GITHUB_SHA:0:7}" +RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + +mkdir -p /tmp/pm-bench-output +{ + echo "## 📊 pm-bench-phases · \`$SHA\` · ${PLATFORM} (\`${OS}\`)" + echo "" + echo "[Workflow run]($RUN_URL) — ant-design" + echo "" + echo "_PMs: \`utoo\` (this branch) · \`utoo-npm\` (latest published) · \`bun\` (latest)_" + echo "" +} > "$OUT" + +# Convert one log file's fixed-width tables into markdown. +render_log() { + local label="$1" + local file="$2" + if [ ! -f "$file" ]; then + echo "_${label}: no output captured._" + echo "" + return + fi + + echo "### ${label}" + echo "" + + # Strip ANSI colour codes so awk sees clean text. + local cleaned + cleaned=$(mktemp) + sed -E 's/\x1B\[[0-9;]*[a-zA-Z]//g' "$file" > "$cleaned" + + awk ' + function flush_table() { + if (n_rows == 0) return + # markdown header + printf "| " + for (i = 1; i <= n_cols; i++) printf "%s | ", header[i] + printf "\n|" + for (i = 1; i <= n_cols; i++) printf "---|" + printf "\n" + # data rows + for (r = 1; r <= n_rows; r++) { + printf "| " + for (i = 1; i <= n_cols; i++) printf "%s | ", rows[r, i] + printf "\n" + } + printf "\n" + n_rows = 0 + n_cols = 0 + } + + /^## p[0-9]+_/ { + flush_table() + # phase heading (drop the leading `## `) + sub(/^## /, "", $0) + printf "#### %s\n\n", $0 + in_phase = 1 + next + } + + in_phase && /^PM[ \t]+/ { + # New table header — flush any preceding one. + flush_table() + n_cols = NF + for (i = 1; i <= NF; i++) header[i] = $i + next + } + + # Data rows: indented or pm-name-leading lines while a header is open. + in_phase && n_cols > 0 && NF == n_cols && $1 !~ /^(PM|##)$/ { + n_rows++ + for (i = 1; i <= NF; i++) rows[n_rows, i] = $i + next + } + + # End-of-phase marker: another `##` heading or blank-then-banner. + in_phase && /^##/ && !/^## p[0-9]+_/ { + flush_table() + in_phase = 0 + next + } + + END { flush_table() } + ' "$cleaned" + + rm -f "$cleaned" +} + +render_log "npmjs.org" /tmp/pm-bench-output/bench-phases-npmjs.log >> "$OUT" +render_log "npmmirror.com" /tmp/pm-bench-output/bench-phases-npmmirror.log >> "$OUT" diff --git a/.github/workflows/pm-bench.yml b/.github/workflows/pm-bench.yml deleted file mode 100644 index 64277e104d..0000000000 --- a/.github/workflows/pm-bench.yml +++ /dev/null @@ -1,338 +0,0 @@ -name: utoopm-benchmark - -on: - workflow_dispatch: - inputs: - registry: - description: 'Registry to test' - required: true - default: 'both' - type: choice - options: - - both - - npmjs - - npmmirror - pm_list: - description: 'Comma-separated list (e.g. utoo,aube or utoo,pnpm,bun,aube); "all" expands to utoo,pnpm,yarn,bun,aube' - required: true - default: 'utoo,bun' - type: string - baseline: - description: 'Compare with published utoo from npm' - required: false - default: true - type: boolean - build_current: - description: 'Build utoo from the current branch (disable to benchmark only the npm baseline + other PMs)' - required: false - default: true - type: boolean - build_next: - description: 'Compare with utoo built from next branch' - required: false - default: false - type: boolean - cold_runs: - description: 'Number of cold install runs' - required: false - default: '3' - type: string - warm_runs: - description: 'Number of warm install runs' - required: false - default: '3' - type: string - pull_request: - types: [labeled] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build-linux: - if: > - (github.event_name == 'workflow_dispatch' && (github.event.inputs.build_current != 'false' || github.event.inputs.build_next == 'true')) || - (github.event_name == 'pull_request' && github.event.label.name == 'benchmark') - runs-on: ubuntu-latest - container: - image: ubuntu:20.04 - steps: - - name: Install dependencies - env: - DEBIAN_FRONTEND: noninteractive - run: apt-get update && apt-get install -y build-essential curl pkg-config libssl-dev git - - - uses: actions/checkout@v4 - - - name: Configure git safe directory - run: git config --global --add safe.directory /__w/utoo/utoo - - - name: Init git submodules - run: git submodule update --init --recursive --depth 1 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly-2026-04-02 - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - with: - shared-key: pm-bench-linux - - - name: Build current branch utoo - if: github.event.inputs.build_current != 'false' - run: cargo build --release --bin utoo -p utoo-pm - - - name: Upload current binary - if: github.event.inputs.build_current != 'false' - uses: actions/upload-artifact@v4 - with: - name: utoo-linux-bench-current - path: target/release/utoo - retention-days: 1 - - - name: Build next branch utoo - if: github.event.inputs.build_next == 'true' - run: | - git fetch origin next --depth=1 - git checkout origin/next - git submodule update --init --recursive --depth 1 - cargo build --release --bin utoo -p utoo-pm - - - name: Upload next binary - if: github.event.inputs.build_next == 'true' - uses: actions/upload-artifact@v4 - with: - name: utoo-linux-bench-next - path: target/release/utoo - retention-days: 1 - - benchmark-linux: - needs: [build-linux] - if: > - always() && - (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') && - (github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.event.label.name == 'benchmark')) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Download current binary - if: github.event.inputs.build_current != 'false' - uses: actions/download-artifact@v4 - with: - name: utoo-linux-bench-current - path: dist - - - name: Setup current binary - if: github.event.inputs.build_current != 'false' - run: | - chmod +x dist/utoo - echo "$GITHUB_WORKSPACE/dist" >> $GITHUB_PATH - - - name: Create ut symlink - if: github.event.inputs.build_current != 'false' - run: ln -sf utoo $(dirname $(which utoo))/ut - - - name: Install baseline utoo from npm - if: github.event.inputs.baseline != 'false' - run: | - npm install -g utoo - if [ "${{ github.event.inputs.build_current }}" != "false" ]; then - BASELINE_PATH=$(which utoo) - cp "$BASELINE_PATH" /tmp/utoo-npm - chmod +x /tmp/utoo-npm - echo "Baseline utoo (npm) version: $(/tmp/utoo-npm --version)" - echo "UTOO_NPM_BIN=/tmp/utoo-npm" >> $GITHUB_ENV - else - ln -sf "$(which utoo)" "$(dirname "$(which utoo)")/ut" - echo "utoo (npm, primary): $(utoo --version)" - fi - - - name: Setup next binary - if: github.event.inputs.build_next == 'true' - uses: actions/download-artifact@v4 - with: - name: utoo-linux-bench-next - path: dist-next - - - name: Install next binary - if: github.event.inputs.build_next == 'true' - run: | - chmod +x dist-next/utoo - cp dist-next/utoo /tmp/utoo-next - echo "Baseline utoo (next) version: $(/tmp/utoo-next --version)" - echo "UTOO_NEXT_BIN=/tmp/utoo-next" >> $GITHUB_ENV - - - name: Resolve pm_list - id: pm - run: | - PM_LIST="${{ github.event.inputs.pm_list || 'utoo,bun' }}" - if [ "$PM_LIST" = "all" ]; then - PM_LIST="utoo,pnpm,yarn,bun,aube" - fi - echo "list=$PM_LIST" >> $GITHUB_OUTPUT - - - name: Install pnpm - if: contains(steps.pm.outputs.list, 'pnpm') - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Install yarn - if: contains(steps.pm.outputs.list, 'yarn') - run: npm install -g yarn - - - name: Install bun - if: contains(steps.pm.outputs.list, 'bun') - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install aube - if: contains(steps.pm.outputs.list, 'aube') - run: npm install -g @endevco/aube - - - name: Install hyperfine and GNU time - run: sudo apt-get update && sudo apt-get install -y hyperfine time - - - name: Verify tools - run: | - echo "hyperfine: $(hyperfine --version)" - IFS=',' read -ra PMS <<< "${{ steps.pm.outputs.list }}" - for pm in "${PMS[@]}"; do - echo "$pm: $($pm --version 2>&1 || echo 'not installed')" - done - - - name: Run benchmark - run: | - chmod +x e2e/pm-bench.sh - BENCH_COLD_RUNS=${{ github.event.inputs.cold_runs || '3' }} \ - BENCH_WARM_RUNS=${{ github.event.inputs.warm_runs || '3' }} \ - bash e2e/pm-bench.sh ${{ github.event.inputs.registry || 'both' }} ${{ steps.pm.outputs.list }} - - benchmark: - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.event.label.name == 'benchmark') - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - name: Init git submodules - if: github.event.inputs.build_current != 'false' || github.event.inputs.build_next == 'true' - run: git submodule update --init --recursive --depth 1 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Rust - if: github.event.inputs.build_current != 'false' || github.event.inputs.build_next == 'true' - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly-2026-04-02 - - - name: Cache cargo - if: github.event.inputs.build_current != 'false' || github.event.inputs.build_next == 'true' - uses: Swatinem/rust-cache@v2 - with: - shared-key: pm-bench-macos-latest - - - name: Install baseline utoo from npm - if: github.event.inputs.baseline != 'false' - run: | - npm install -g utoo - if [ "${{ github.event.inputs.build_current }}" != "false" ]; then - BASELINE_PATH=$(which utoo) - cp "$BASELINE_PATH" /tmp/utoo-npm - chmod +x /tmp/utoo-npm - echo "Baseline utoo (npm) version: $(/tmp/utoo-npm --version)" - echo "UTOO_NPM_BIN=/tmp/utoo-npm" >> $GITHUB_ENV - else - ln -sf "$(which utoo)" "$(dirname "$(which utoo)")/ut" - echo "utoo (npm, primary): $(utoo --version)" - fi - - - name: Build baseline utoo from next branch - if: github.event.inputs.build_next == 'true' - run: | - CURRENT_SHA=$(git rev-parse HEAD) - git fetch origin next --depth=1 - git checkout origin/next - git submodule update --init --recursive --depth 1 - cargo build --release --bin utoo -p utoo-pm - cp target/release/utoo /tmp/utoo-next - chmod +x /tmp/utoo-next - echo "Baseline utoo (next) version: $(/tmp/utoo-next --version)" - echo "UTOO_NEXT_BIN=/tmp/utoo-next" >> $GITHUB_ENV - git checkout "$CURRENT_SHA" - git submodule update --init --recursive --depth 1 - - - name: Build utoo-pm - if: github.event.inputs.build_current != 'false' - run: cargo build --release --bin utoo -p utoo-pm - - - name: Update PATH - if: github.event.inputs.build_current != 'false' - run: echo "${{ github.workspace }}/target/release" >> $GITHUB_PATH - - - name: Create ut symlink - if: github.event.inputs.build_current != 'false' - run: ln -sf utoo $(dirname $(which utoo))/ut - - - name: Resolve pm_list - id: pm - run: | - PM_LIST="${{ github.event.inputs.pm_list || 'utoo,bun' }}" - if [ "$PM_LIST" = "all" ]; then - PM_LIST="utoo,pnpm,yarn,bun,aube" - fi - echo "list=$PM_LIST" >> $GITHUB_OUTPUT - - - name: Install pnpm - if: contains(steps.pm.outputs.list, 'pnpm') - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Install yarn - if: contains(steps.pm.outputs.list, 'yarn') - run: npm install -g yarn - - - name: Install bun - if: contains(steps.pm.outputs.list, 'bun') - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install aube - if: contains(steps.pm.outputs.list, 'aube') - run: npm install -g @endevco/aube - - - name: Install hyperfine - run: brew install hyperfine - - - name: Verify tools - run: | - echo "hyperfine: $(hyperfine --version)" - IFS=',' read -ra PMS <<< "${{ steps.pm.outputs.list }}" - for pm in "${PMS[@]}"; do - echo "$pm: $($pm --version 2>&1 || echo 'not installed')" - done - - - name: Run benchmark - run: | - chmod +x e2e/pm-bench.sh - BENCH_COLD_RUNS=${{ github.event.inputs.cold_runs || '3' }} \ - BENCH_WARM_RUNS=${{ github.event.inputs.warm_runs || '3' }} \ - bash e2e/pm-bench.sh ${{ github.event.inputs.registry || 'both' }} ${{ steps.pm.outputs.list }} diff --git a/.github/workflows/pm-e2e-bench.yml b/.github/workflows/pm-e2e-bench.yml new file mode 100644 index 0000000000..a2a3f9c348 --- /dev/null +++ b/.github/workflows/pm-e2e-bench.yml @@ -0,0 +1,792 @@ +name: utoopm-e2e-bench + +# Single workflow that builds utoo once per target, then fans out to +# every consumer (e2e, bench-phases, pm-bench-all, pm-bench-pcap). +# Replaces the old pm-e2e.yml + pm-bench.yml + pm-bench-phases.yml +# trio so each target builds exactly once per commit and downstream +# jobs reuse the uploaded artifact. +# +# Triggers (event → consumers): +# pull_request / push with `(pm)` in title/msg → e2e jobs +# pull_request labeled `benchmark` → bench-phases (utoo / utoo-npm / bun) +# workflow_dispatch with `target` choice → manual one-off runs +# (pm-bench-all = full PM shootout vs pnpm/yarn/bun/aube; +# pm-bench-phases = same as the labeled trigger but ad-hoc; +# pm-bench-pcap = tcpdump capture for resolve phase) +# +# Build job gates are the union of their downstream consumers' gates. +# Each downstream job is pure download-and-run, no `cargo build`. +on: + push: + branches: ["next", "e2e-pm"] + pull_request: + types: [opened, synchronize, reopened, edited, labeled] + workflow_dispatch: + inputs: + target: + description: 'Manual target to run' + required: true + type: choice + options: + - pm-bench-all + - pm-bench-phases + - pm-bench-pcap + default: pm-bench-all + # ----- pm-bench-all inputs ----- + registry: + description: '[pm-bench-all] Registry to test' + required: false + default: 'both' + type: choice + options: [both, npmjs, npmmirror] + pm_list: + description: '[pm-bench-all] Comma-separated PMs (e.g. utoo,bun); "all" → utoo,pnpm,yarn,bun,aube' + required: false + default: 'utoo,bun' + type: string + baseline: + description: '[pm-bench-all] Compare with published utoo from npm' + required: false + default: true + type: boolean + build_next: + description: '[pm-bench-all] Also compare with utoo built from `next` branch' + required: false + default: false + type: boolean + cold_runs: + description: '[pm-bench-all] Number of cold install runs' + required: false + default: '3' + type: string + warm_runs: + description: '[pm-bench-all] Number of warm install runs' + required: false + default: '3' + type: string + # ----- pm-bench-phases / pcap inputs ----- + project: + description: '[phases/pcap] Project to bench (under github.com/ant-design/)' + required: false + default: 'ant-design' + type: string + bench_runs: + description: '[phases] Runs per phase' + required: false + default: '3' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # ============================================================ + # BUILD JOBS + # One per target, runs whenever any downstream consumer needs it. + # Downstream jobs `needs:` the relevant build job and download + # the uploaded artifact. No downstream rebuilds. + # ============================================================ + + build-linux: + # Linux serves: e2e-linux, bench-phases-linux, pm-bench-all-linux, pm-bench-pcap-linux. + # Gate is the union of all linux consumers' gates. + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'benchmark')) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + container: + image: ubuntu:20.04 + steps: + - name: Install dependencies + timeout-minutes: 6 + env: + DEBIAN_FRONTEND: noninteractive + # Short per-apt timeouts: on GHA runners that can't route to + # archive/security.ubuntu.com, apt otherwise sits in 90s × 8-IP SYN + # timeouts. Bail after 60s on the default mirror, swap to the + # Fastly-backed kernel.org mirror, and retry once. + run: | + install_pkgs() { + timeout 60 apt-get update \ + && timeout 60 apt-get install -y build-essential curl pkg-config git + } + install_pkgs && exit 0 + echo "apt default mirror failed/timed out, falling back to mirrors.edge.kernel.org" + dpkg --configure -a || true + sed -i 's|http://archive\.ubuntu\.com|http://mirrors.edge.kernel.org|g; s|http://security\.ubuntu\.com|http://mirrors.edge.kernel.org|g' /etc/apt/sources.list + install_pkgs + - uses: actions/checkout@v4 + - name: Configure git safe directory + run: git config --global --add safe.directory /__w/utoo/utoo + - name: Init git submodules + run: git submodule update --init --recursive --depth 1 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-02 + targets: x86_64-unknown-linux-gnu + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: pm-build-linux + - name: Build utoo-pm + run: cargo build --release --target x86_64-unknown-linux-gnu --bin utoo -p utoo-pm + - name: Upload utoo binary + uses: actions/upload-artifact@v4 + with: + name: utoo-linux-x64 + path: target/x86_64-unknown-linux-gnu/release/utoo + retention-days: 1 + + build-mac-arm64: + # Mac arm64 serves: e2e-mac-arm64, bench-phases-mac, pm-bench-all-mac. + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'benchmark')) || + (github.event_name == 'workflow_dispatch' && inputs.target != 'pm-bench-pcap') + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Init git submodules + run: git submodule update --init --recursive --depth 1 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-02 + targets: aarch64-apple-darwin + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: pm-build-mac-arm64 + - name: Build utoo-pm + run: cargo build --release --target aarch64-apple-darwin --bin utoo -p utoo-pm + - name: Upload utoo binary + uses: actions/upload-artifact@v4 + with: + name: utoo-mac-arm64 + path: target/aarch64-apple-darwin/release/utoo + retention-days: 1 + + build-mac-x64: + # Mac x86_64 serves: e2e-mac-x64 only. Cross-compiled on macos-latest + # (arm64 host) via `rustup target add x86_64-apple-darwin`. + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Init git submodules + run: git submodule update --init --recursive --depth 1 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-02 + targets: x86_64-apple-darwin + - name: Add cross-compilation target + run: rustup target add x86_64-apple-darwin + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: pm-build-mac-x64 + - name: Build utoo-pm + run: cargo build --release --target x86_64-apple-darwin --bin utoo -p utoo-pm + - name: Upload utoo binary + uses: actions/upload-artifact@v4 + with: + name: utoo-mac-x64 + path: target/x86_64-apple-darwin/release/utoo + retention-days: 1 + + build-windows: + # Windows serves: e2e-windows only. + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Disable Windows Defender + shell: powershell + run: Set-MpPreference -DisableRealtimeMonitoring $true + - name: Init git submodules + run: git submodule update --init --recursive --depth 1 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-02 + targets: x86_64-pc-windows-msvc + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: pm-build-windows + - name: Build utoo-pm + run: cargo build --release --target x86_64-pc-windows-msvc --bin utoo -p utoo-pm + - name: Upload utoo binary + uses: actions/upload-artifact@v4 + with: + name: utoo-windows-x64 + path: target/x86_64-pc-windows-msvc/release/utoo.exe + retention-days: 1 + + # ============================================================ + # E2E JOBS + # Auto-trigger on `(pm)` (strict) PR title / commit msg. + # ============================================================ + + e2e-linux: + needs: [build-linux] + name: utoopm-e2e-ubuntu-latest-x86_64-unknown-linux-gnu + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-linux-x64 + - name: Verify tools + run: | + utoo --version + ut --version + - name: Run e2e tests + run: | + chmod +x e2e/utoo-pm.sh e2e/pm-arborist.sh + bash e2e/utoo-pm.sh + bash e2e/pm-arborist.sh + - name: Upload e2e artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts-ubuntu-latest-x86_64-unknown-linux-gnu + path: /tmp/utoo-* + if-no-files-found: ignore + + e2e-mac-arm64: + needs: [build-mac-arm64] + name: utoopm-e2e-macos-latest-aarch64-apple-darwin + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-mac-arm64 + - name: Verify tools + run: | + utoo --version + ut --version + - name: Run e2e tests + run: | + chmod +x e2e/utoo-pm.sh e2e/pm-arborist.sh + bash e2e/utoo-pm.sh + bash e2e/pm-arborist.sh + - name: Upload e2e artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts-macos-latest-aarch64-apple-darwin + path: /tmp/utoo-* + if-no-files-found: ignore + + e2e-mac-x64: + needs: [build-mac-x64] + name: utoopm-e2e-macos-latest-x86_64-apple-darwin + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Install x86_64 Node.js via nvm + run: | + arch -x86_64 bash -c ' + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm install 22 + nvm use 22 + nvm alias default 22 + echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV + NODE_PATH=$(find $HOME/.nvm/versions/node -name "v22.*" -type d | head -1) + echo "$NODE_PATH/bin" >> $GITHUB_PATH + node -p process.arch + npm -v + ' + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-mac-x64 + - name: Verify tools + run: | + utoo --version + ut --version + - name: Run e2e tests (under arch -x86_64) + shell: bash + run: | + chmod +x e2e/utoo-pm.sh e2e/pm-arborist.sh + arch -x86_64 bash -c ' + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm use 22 + bash e2e/utoo-pm.sh + bash e2e/pm-arborist.sh + ' + - name: Upload e2e artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts-macos-latest-x86_64-apple-darwin + path: /tmp/utoo-* + if-no-files-found: ignore + + e2e-windows: + needs: [build-windows] + name: utoopm-e2e-windows-latest-x86_64-pc-windows-msvc + if: > + (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || + (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Disable Windows Defender + shell: powershell + run: Set-MpPreference -DisableRealtimeMonitoring $true + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Download utoo binary + uses: actions/download-artifact@v4 + with: + name: utoo-windows-x64 + path: dist + - name: Install utoo + shell: pwsh + run: | + $binPath = "${{ github.workspace }}\dist" + # ut.exe is just a copy of utoo.exe (Windows has no symlinks). + Copy-Item "$binPath\utoo.exe" "$binPath\ut.exe" -Force + echo "$binPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Verify tools + shell: pwsh + run: | + $binPath = "${{ github.workspace }}\dist" + $env:PATH = "$binPath;$env:PATH" + Get-Command utoo | Select-Object -ExpandProperty Source + Get-Command ut | Select-Object -ExpandProperty Source + utoo --version + ut --version + - name: Run e2e tests + shell: pwsh + run: | + git config --system core.longpaths true + $binPath = "${{ github.workspace }}\dist" + $env:PATH = "$binPath;$env:PATH" + pwsh -File e2e/utoo-pm.ps1 --verbose + - name: Upload e2e artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts-windows-latest-x86_64-pc-windows-msvc + path: ${{ runner.temp }}/utoo-* + if-no-files-found: ignore + + e2e-success: + name: utoopm-e2e-success + runs-on: ubuntu-latest + if: always() + needs: [build-linux, build-mac-arm64, build-mac-x64, build-windows, e2e-linux, e2e-mac-arm64, e2e-mac-x64, e2e-windows] + steps: + - name: Check success + run: | + for result in \ + "${{ needs.build-linux.result }}" \ + "${{ needs.build-mac-arm64.result }}" \ + "${{ needs.build-mac-x64.result }}" \ + "${{ needs.build-windows.result }}" \ + "${{ needs.e2e-linux.result }}" \ + "${{ needs.e2e-mac-arm64.result }}" \ + "${{ needs.e2e-mac-x64.result }}" \ + "${{ needs.e2e-windows.result }}"; do + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + echo "Tests failed or were cancelled" + exit 1 + fi + done + echo "Tests passed or were skipped" + + # ============================================================ + # BENCH-PHASES JOBS + # Auto-trigger on `(pm` (loose) — covers `(pm)`, `(pm-bench-phases)`, + # `(pm-foo)`, etc. Posts a sticky PR comment per platform with the + # phase-isolated bench results across npmjs + npmmirror. + # ============================================================ + + bench-phases-linux: + needs: [build-linux] + if: > + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'benchmark')) || + (github.event_name == 'workflow_dispatch' && inputs.target == 'pm-bench-phases') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-linux-x64 + with-ut-symlink: 'false' + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: ./.github/actions/install-bench-tools + with: + platform: linux + - uses: ./.github/actions/install-utoo-npm-baseline + - name: Verify tools + run: | + hyperfine --version + utoo --version + "$UTOO_NPM_BIN" --version + bun --version + - name: Define cache wipe function + run: | + cat > /tmp/wipe-bench-state.sh <<'WIPE' + #!/usr/bin/env bash + rm -rf /tmp/pm-bench /tmp/pm-bench-results /tmp/pm-bench-output \ + /tmp/utoo-bench-cache /tmp/utoo-npm-bench-cache /tmp/bun-bench-cache \ + ~/.bun/install/cache ~/.cache/nm 2>/dev/null || true + mkdir -p /tmp/pm-bench-output + WIPE + chmod +x /tmp/wipe-bench-state.sh + - name: Wipe bench state before npmjs + run: bash /tmp/wipe-bench-state.sh + - name: Run phase-isolated benchmark · npmjs + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: 'https://registry.npmjs.org' + BENCH_RUNS: ${{ github.event.inputs.bench_runs || '3' }} + PM_LIST: 'utoo,utoo-npm,bun' + run: | + chmod +x bench/pm-bench-phases.sh + bash bench/pm-bench-phases.sh 2>&1 | tee /tmp/pm-bench-output/bench-phases-npmjs.log + - name: Stash npmjs logs and wipe state before npmmirror + run: | + mkdir -p /tmp/bench-logs-stash + cp -r /tmp/pm-bench-output/. /tmp/bench-logs-stash/ 2>/dev/null || true + bash /tmp/wipe-bench-state.sh + mkdir -p /tmp/pm-bench-output + cp -r /tmp/bench-logs-stash/. /tmp/pm-bench-output/ 2>/dev/null || true + - name: Run phase-isolated benchmark · npmmirror + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: 'https://registry.npmmirror.com' + BENCH_RUNS: ${{ github.event.inputs.bench_runs || '3' }} + PM_LIST: 'utoo,utoo-npm,bun' + run: | + mkdir -p /tmp/pm-bench-output + bash bench/pm-bench-phases.sh 2>&1 | tee /tmp/pm-bench-output/bench-phases-npmmirror.log + - name: Upload bench logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: pm-bench-logs-linux + path: /tmp/pm-bench-output + retention-days: 7 + - if: always() + uses: ./.github/actions/post-bench-phases-comment + with: + platform: linux + os: ubuntu-latest + github-token: ${{ secrets.GITHUB_TOKEN }} + + bench-phases-mac: + needs: [build-mac-arm64] + if: > + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'benchmark')) || + (github.event_name == 'workflow_dispatch' && inputs.target == 'pm-bench-phases') + runs-on: macos-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-mac-arm64 + with-ut-symlink: 'false' + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: ./.github/actions/install-bench-tools + with: + platform: mac + - uses: ./.github/actions/install-utoo-npm-baseline + - name: Verify tools + run: | + hyperfine --version + utoo --version + "$UTOO_NPM_BIN" --version + bun --version + - name: Define cache wipe function + run: | + cat > /tmp/wipe-bench-state.sh <<'WIPE' + #!/usr/bin/env bash + rm -rf /tmp/pm-bench /tmp/pm-bench-results /tmp/pm-bench-output \ + /tmp/utoo-bench-cache /tmp/utoo-npm-bench-cache /tmp/bun-bench-cache \ + ~/.bun/install/cache ~/.cache/nm 2>/dev/null || true + mkdir -p /tmp/pm-bench-output + WIPE + chmod +x /tmp/wipe-bench-state.sh + - name: Wipe bench state before npmjs + run: bash /tmp/wipe-bench-state.sh + - name: Run phase-isolated benchmark · npmjs + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: 'https://registry.npmjs.org' + BENCH_RUNS: ${{ github.event.inputs.bench_runs || '3' }} + PM_LIST: 'utoo,utoo-npm,bun' + run: | + chmod +x bench/pm-bench-phases.sh + bash bench/pm-bench-phases.sh 2>&1 | tee /tmp/pm-bench-output/bench-phases-npmjs.log + - name: Stash npmjs logs and wipe state before npmmirror + run: | + mkdir -p /tmp/bench-logs-stash + cp -r /tmp/pm-bench-output/. /tmp/bench-logs-stash/ 2>/dev/null || true + bash /tmp/wipe-bench-state.sh + mkdir -p /tmp/pm-bench-output + cp -r /tmp/bench-logs-stash/. /tmp/pm-bench-output/ 2>/dev/null || true + - name: Run phase-isolated benchmark · npmmirror + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: 'https://registry.npmmirror.com' + BENCH_RUNS: ${{ github.event.inputs.bench_runs || '3' }} + PM_LIST: 'utoo,utoo-npm,bun' + run: | + mkdir -p /tmp/pm-bench-output + bash bench/pm-bench-phases.sh 2>&1 | tee /tmp/pm-bench-output/bench-phases-npmmirror.log + - name: Upload bench logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: pm-bench-logs-mac + path: /tmp/pm-bench-output + retention-days: 7 + - if: always() + uses: ./.github/actions/post-bench-phases-comment + with: + platform: mac + os: macos-latest + github-token: ${{ secrets.GITHUB_TOKEN }} + + # ============================================================ + # PM-BENCH-ALL (manual full PM shootout) + # workflow_dispatch with target=pm-bench-all only — comparing utoo + # against pnpm/yarn/bun/aube on cold + warm installs across + # configurable registries is slow, so it's never auto-triggered. + # ============================================================ + + pm-bench-all-linux: + needs: [build-linux] + if: github.event_name == 'workflow_dispatch' && inputs.target == 'pm-bench-all' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-linux-x64 + - if: github.event.inputs.baseline != 'false' + uses: ./.github/actions/install-utoo-npm-baseline + - name: Build next branch utoo + if: github.event.inputs.build_next == 'true' + run: | + # The PATH already points at dist/utoo (current branch) — build + # `next` separately to /tmp so it doesn't shadow. + git fetch origin next --depth=1 + WORKDIR=$(mktemp -d) + git --work-tree="$WORKDIR" --git-dir="$(pwd)/.git" checkout origin/next -- . + (cd "$WORKDIR" && git submodule update --init --recursive --depth 1 && cargo build --release --bin utoo -p utoo-pm) + cp "$WORKDIR/target/release/utoo" /tmp/utoo-next + chmod +x /tmp/utoo-next + echo "Baseline utoo (next) version: $(/tmp/utoo-next --version)" + echo "UTOO_NEXT_BIN=/tmp/utoo-next" >> $GITHUB_ENV + - name: Resolve pm_list + id: pm + run: | + PM_LIST="${{ github.event.inputs.pm_list || 'utoo,bun' }}" + if [ "$PM_LIST" = "all" ]; then + PM_LIST="utoo,pnpm,yarn,bun,aube" + fi + echo "list=$PM_LIST" >> $GITHUB_OUTPUT + - name: Install pnpm + if: contains(steps.pm.outputs.list, 'pnpm') + uses: pnpm/action-setup@v4 + with: + version: latest + - name: Install yarn + if: contains(steps.pm.outputs.list, 'yarn') + run: npm install -g yarn + - name: Install bun + if: contains(steps.pm.outputs.list, 'bun') + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install aube + if: contains(steps.pm.outputs.list, 'aube') + run: npm install -g @endevco/aube + - name: Install hyperfine and GNU time + run: sudo apt-get update && sudo apt-get install -y hyperfine time + - name: Verify tools + run: | + hyperfine --version + IFS=',' read -ra PMS <<< "${{ steps.pm.outputs.list }}" + for pm in "${PMS[@]}"; do + echo "$pm: $($pm --version 2>&1 || echo 'not installed')" + done + - name: Run benchmark + run: | + chmod +x bench/pm-bench.sh + BENCH_COLD_RUNS=${{ github.event.inputs.cold_runs || '3' }} \ + BENCH_WARM_RUNS=${{ github.event.inputs.warm_runs || '3' }} \ + bash bench/pm-bench.sh ${{ github.event.inputs.registry || 'both' }} ${{ steps.pm.outputs.list }} + + pm-bench-all-mac: + needs: [build-mac-arm64] + if: github.event_name == 'workflow_dispatch' && inputs.target == 'pm-bench-all' + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-mac-arm64 + - if: github.event.inputs.baseline != 'false' + uses: ./.github/actions/install-utoo-npm-baseline + - name: Build next branch utoo + if: github.event.inputs.build_next == 'true' + run: | + git fetch origin next --depth=1 + WORKDIR=$(mktemp -d) + git --work-tree="$WORKDIR" --git-dir="$(pwd)/.git" checkout origin/next -- . + (cd "$WORKDIR" && git submodule update --init --recursive --depth 1 && cargo build --release --bin utoo -p utoo-pm) + cp "$WORKDIR/target/release/utoo" /tmp/utoo-next + chmod +x /tmp/utoo-next + echo "Baseline utoo (next) version: $(/tmp/utoo-next --version)" + echo "UTOO_NEXT_BIN=/tmp/utoo-next" >> $GITHUB_ENV + - name: Resolve pm_list + id: pm + run: | + PM_LIST="${{ github.event.inputs.pm_list || 'utoo,bun' }}" + if [ "$PM_LIST" = "all" ]; then + PM_LIST="utoo,pnpm,yarn,bun,aube" + fi + echo "list=$PM_LIST" >> $GITHUB_OUTPUT + - name: Install pnpm + if: contains(steps.pm.outputs.list, 'pnpm') + uses: pnpm/action-setup@v4 + with: + version: latest + - name: Install yarn + if: contains(steps.pm.outputs.list, 'yarn') + run: npm install -g yarn + - name: Install bun + if: contains(steps.pm.outputs.list, 'bun') + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install aube + if: contains(steps.pm.outputs.list, 'aube') + run: npm install -g @endevco/aube + - name: Install hyperfine + run: brew install hyperfine + - name: Verify tools + run: | + hyperfine --version + IFS=',' read -ra PMS <<< "${{ steps.pm.outputs.list }}" + for pm in "${PMS[@]}"; do + echo "$pm: $($pm --version 2>&1 || echo 'not installed')" + done + - name: Run benchmark + run: | + chmod +x bench/pm-bench.sh + BENCH_COLD_RUNS=${{ github.event.inputs.cold_runs || '3' }} \ + BENCH_WARM_RUNS=${{ github.event.inputs.warm_runs || '3' }} \ + bash bench/pm-bench.sh ${{ github.event.inputs.registry || 'both' }} ${{ steps.pm.outputs.list }} + + # ============================================================ + # PM-BENCH-PCAP (manual perf-debug only) + # workflow_dispatch with target=pm-bench-pcap. Captures tcpdump + # of the resolve phase per PM (linux only — sudo + raw sockets). + # ============================================================ + + pm-bench-pcap-linux: + needs: [build-linux] + if: github.event_name == 'workflow_dispatch' && inputs.target == 'pm-bench-pcap' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: ./.github/actions/setup-utoo-bin + with: + artifact: utoo-linux-x64 + with-ut-symlink: 'false' + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install pcap tools + # tcpdump + dnsutils on top of the standard bench tooling. + run: sudo apt-get update && sudo apt-get install -y tcpdump dnsutils hyperfine time + - name: Capture pcap + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: 'https://registry.npmjs.org' + run: | + chmod +x bench/pm-bench-pcap.sh + bash bench/pm-bench-pcap.sh + - name: Upload pcap artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: pm-bench-pcap + path: /tmp/pm-bench-pcap + retention-days: 7 diff --git a/.github/workflows/pm-e2e.yml b/.github/workflows/pm-e2e.yml deleted file mode 100644 index e4d7815c53..0000000000 --- a/.github/workflows/pm-e2e.yml +++ /dev/null @@ -1,321 +0,0 @@ -name: utoopm-e2e - -on: - push: - branches: ["next", "e2e-pm"] - pull_request: - types: [opened, synchronize, reopened, edited] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build-linux-x64: - if: (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) - runs-on: ubuntu-latest - container: - image: ubuntu:20.04 - steps: - - name: Install dependencies - timeout-minutes: 6 - env: - DEBIAN_FRONTEND: noninteractive - # Short per-apt timeouts: on GHA runners that can't route to - # archive/security.ubuntu.com, apt otherwise sits in 90s × 8-IP SYN - # timeouts. Bail after 60s on the default mirror, swap to the - # Fastly-backed kernel.org mirror, and retry once. - # - # If the first attempt times out mid-unpack (e.g. libc6 upgrade), - # dpkg is left half-configured and the retry fails with - # "dpkg was interrupted". `dpkg --configure -a` finalises any - # pending unpack before we retry on the fallback mirror. - run: | - install_pkgs() { - timeout 60 apt-get update \ - && timeout 60 apt-get install -y build-essential curl pkg-config git - } - install_pkgs && exit 0 - echo "apt default mirror failed/timed out, falling back to mirrors.edge.kernel.org" - dpkg --configure -a || true - sed -i 's|http://archive\.ubuntu\.com|http://mirrors.edge.kernel.org|g; s|http://security\.ubuntu\.com|http://mirrors.edge.kernel.org|g' /etc/apt/sources.list - install_pkgs - - - uses: actions/checkout@v4 - - - name: Configure git safe directory - run: git config --global --add safe.directory /__w/utoo/utoo - - - name: Init git submodules - run: git submodule update --init --recursive --depth 1 - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly-2026-04-02 - targets: x86_64-unknown-linux-gnu - - - name: Build utoo-pm - run: cargo build --release --target x86_64-unknown-linux-gnu --bin utoo -p utoo-pm - - - name: Upload binary - uses: actions/upload-artifact@v4 - with: - name: utoo-linux-x64 - path: target/x86_64-unknown-linux-gnu/release/utoo - retention-days: 1 - - e2e-linux: - needs: [build-linux-x64] - name: utoopm-e2e-ubuntu-latest-x86_64-unknown-linux-gnu - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Download linux binary - uses: actions/download-artifact@v4 - with: - name: utoo-linux-x64 - path: dist - - - name: Update PATH - run: | - chmod +x dist/utoo - echo "${{ github.workspace }}/dist" >> $GITHUB_PATH - - - name: Create ut symlink - run: ln -sf utoo $(dirname $(which utoo))/ut - - - name: Verify tools - run: | - echo "utoo: $(which utoo)" - echo "ut: $(which ut)" - utoo --version - ut --version - - - name: Make scripts executable - run: chmod +x e2e/utoo-pm.sh e2e/pm-arborist.sh - - - name: Run e2e tests - run: bash e2e/utoo-pm.sh - - - name: Run arborist e2e tests - run: bash e2e/pm-arborist.sh - - - name: Upload e2e artifacts on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: e2e-artifacts-ubuntu-latest-x86_64-unknown-linux-gnu - path: /tmp/utoo-* - if-no-files-found: ignore - - e2e: - if: (github.event_name == 'push' && contains(github.event.head_commit.message, '(pm)')) || (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pm)')) - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: aarch64-apple-darwin - - host: macos-latest - target: x86_64-apple-darwin - - host: windows-latest - target: x86_64-pc-windows-msvc - - name: utoopm-e2e-${{ matrix.settings.host }}-${{ matrix.settings.target }} - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v4 - - - name: Init git submodules - run: git submodule update --init --recursive --depth 1 - - # Disable Windows Defender real-time scanning to speed up I/O-heavy builds (~30-50% faster) - - name: Disable Windows Defender - if: runner.os == 'Windows' - shell: powershell - run: Set-MpPreference -DisableRealtimeMonitoring $true - - - name: Install x86_64 Node.js via nvm - if: matrix.settings.target == 'x86_64-apple-darwin' - run: | - arch -x86_64 bash -c ' - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm install 22 - nvm use 22 - nvm alias default 22 - echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV - NODE_PATH=$(find $HOME/.nvm/versions/node -name "v22.*" -type d | head -1) - echo "$NODE_PATH/bin" >> $GITHUB_PATH - node -p process.arch - npm -v - ' - - - name: Setup node - if: matrix.settings.target != 'x86_64-apple-darwin' - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly-2026-04-02 - targets: ${{ matrix.settings.target }} - - - name: Add cross-compilation target - if: matrix.settings.target == 'x86_64-apple-darwin' - run: rustup target add x86_64-apple-darwin - - - name: Build utoo-pm - run: cargo build --release --target ${{ matrix.settings.target }} --bin utoo -p utoo-pm - - - name: Update PATH (Unix) - if: runner.os != 'Windows' - run: echo "${{ github.workspace }}/target/${{ matrix.settings.target }}/release" >> $GITHUB_PATH - - - name: Update PATH (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $binPath = "${{ github.workspace }}\target\${{ matrix.settings.target }}\release" - echo $binPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - Write-Host "Added to PATH: $binPath" - - - name: Create ut symlink for utoo (Unix) - if: runner.os != 'Windows' - run: ln -sf utoo $(dirname $(which utoo))/ut - - - name: Create ut copy for utoo (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $binPath = "${{ github.workspace }}\target\${{ matrix.settings.target }}\release" - $utooPath = Join-Path $binPath "utoo.exe" - $utPath = Join-Path $binPath "ut.exe" - - if (Test-Path $utooPath) { - Copy-Item $utooPath $utPath -Force - Write-Host "Created ut.exe at: $utPath" - } else { - Write-Error "utoo.exe not found at: $utooPath" - exit 1 - } - - - name: Verify tools (Unix) - if: runner.os != 'Windows' - run: | - echo "utoo: $(which utoo)" - echo "ut: $(which ut)" - utoo --version - ut --version - - - name: Verify tools (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $binPath = "${{ github.workspace }}\target\${{ matrix.settings.target }}\release" - Write-Host "Binary path: $binPath" - Write-Host "utoo.exe: $(Test-Path "$binPath\utoo.exe")" - Write-Host "ut.exe: $(Test-Path "$binPath\ut.exe")" - - $env:PATH = "$binPath;$env:PATH" - - Get-Command utoo | Select-Object -ExpandProperty Source - Get-Command ut | Select-Object -ExpandProperty Source - utoo --version - ut --version - - - name: Make scripts executable - if: runner.os != 'Windows' - run: chmod +x e2e/utoo-pm.sh e2e/pm-arborist.sh - - - name: Run e2e tests (Unix) - if: runner.os != 'Windows' - run: | - if [ "${{ matrix.settings.target }}" = "x86_64-apple-darwin" ]; then - arch -x86_64 bash -c ' - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm use 22 - bash e2e/utoo-pm.sh - ' - else - bash e2e/utoo-pm.sh - fi - shell: bash - - - name: Run arborist e2e tests (Unix) - if: runner.os != 'Windows' - run: | - if [ "${{ matrix.settings.target }}" = "x86_64-apple-darwin" ]; then - arch -x86_64 bash -c ' - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm use 22 - bash e2e/pm-arborist.sh - ' - else - bash e2e/pm-arborist.sh - fi - shell: bash - - - name: Run e2e tests (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - git config --system core.longpaths true - - $binPath = "${{ github.workspace }}\target\${{ matrix.settings.target }}\release" - $env:PATH = "$binPath;$env:PATH" - - Write-Host "Running e2e tests..." - Write-Host "Working directory: $(Get-Location)" - - pwsh -File e2e/utoo-pm.ps1 --verbose - - - name: Upload e2e artifacts on failure (Unix) - if: failure() && runner.os != 'Windows' - uses: actions/upload-artifact@v4 - with: - name: e2e-artifacts-${{ matrix.settings.host }}-${{ matrix.settings.target }} - path: /tmp/utoo-* - if-no-files-found: ignore - - - name: Upload e2e artifacts on failure (Windows) - if: failure() && runner.os == 'Windows' - uses: actions/upload-artifact@v4 - with: - name: e2e-artifacts-${{ matrix.settings.host }}-${{ matrix.settings.target }} - path: ${{ runner.temp }}/utoo-* - if-no-files-found: ignore - - success: - name: utoopm-e2e-success - runs-on: ubuntu-latest - if: always() - needs: - - build-linux-x64 - - e2e-linux - - e2e - steps: - - name: Check success - run: | - for result in "${{ needs.build-linux-x64.result }}" "${{ needs.e2e-linux.result }}" "${{ needs.e2e.result }}"; do - if [ "$result" == "failure" ] || [ "$result" == "cancelled" ]; then - echo "Tests failed or were cancelled" - exit 1 - fi - done - echo "Tests passed or were skipped" diff --git a/bench/pm-bench-pcap.sh b/bench/pm-bench-pcap.sh new file mode 100755 index 0000000000..c8e869d78e --- /dev/null +++ b/bench/pm-bench-pcap.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# One-off network trace capture: runs a single cold p1_resolve per PM with +# tcpdump active, so we can inspect connection topology (IPs, concurrency, +# timing) on CI after local evidence was inconclusive. +# +# Outputs: +# $PCAP_DIR/dns.txt — A records for the registry host +# $PCAP_DIR/{bun,utoo}.pcap — tcpdump capture of the resolve phase +# $PCAP_DIR/{bun,utoo}.log — stdout/stderr of the run +set -eo pipefail + +PROJECT=${PROJECT:-ant-design} +REGISTRY=${REGISTRY:-https://registry.npmjs.org} +BENCH_DIR=${BENCH_DIR:-/tmp/pm-bench} +PCAP_DIR=${PCAP_DIR:-/tmp/pm-bench-pcap} +UTOO_CACHE=/tmp/utoo-pcap-cache +BUN_CACHE=/tmp/bun-pcap-cache +export BUN_INSTALL_CACHE_DIR="$BUN_CACHE" + +mkdir -p "$PCAP_DIR" + +# Project must already be cloned by pm-bench-phases.sh (this script is meant +# to run after it, reusing the clone). +PROJECT_DIR="$BENCH_DIR/$PROJECT" +if [ ! -d "$PROJECT_DIR" ]; then + echo "missing $PROJECT_DIR — run pm-bench-phases.sh first" >&2 + exit 1 +fi + +# Extract hostname (strip scheme + any path) for DNS lookup. +HOST=$(echo "$REGISTRY" | sed -E 's#^https?://([^/]+).*#\1#') +echo "=== DNS records for $HOST ===" +{ + echo "# getent hosts" + getent hosts "$HOST" || true + echo + echo "# dig +short A" + dig +short A "$HOST" || true + echo + echo "# dig +short AAAA" + dig +short AAAA "$HOST" || true +} | tee "$PCAP_DIR/dns.txt" + +IFACE=$(ip route | awk '/default/ {print $5; exit}') +echo "capturing on interface: $IFACE" + +capture_one() { + local name=$1 + shift + local pcap="$PCAP_DIR/$name.pcap" + local log="$PCAP_DIR/$name.log" + + cd "$PROJECT_DIR" + # Cold: nothing reused. + rm -f package-lock.json bun.lock + rm -rf "$UTOO_CACHE" "$BUN_CACHE" node_modules + + echo "=== capturing $name ===" + # tcpdump as root; ubuntu-latest runners allow sudo without password. + sudo tcpdump -s 0 -i "$IFACE" -w "$pcap" 'tcp port 443' >/dev/null 2>&1 & + local tcpdump_pid=$! + # Let tcpdump bind before the workload starts. + sleep 1 + + /usr/bin/time -v "$@" >"$log" 2>&1 || echo " run exited with $?" + + sudo kill "$tcpdump_pid" 2>/dev/null || true + wait "$tcpdump_pid" 2>/dev/null || true + + # Make pcap readable by the later upload-artifact step (tcpdump writes + # as root). + sudo chmod 644 "$pcap" + echo " → $pcap ($(wc -c <"$pcap") bytes), log: $log" +} + +capture_one utoo \ + utoo deps --registry="$REGISTRY" --cache-dir="$UTOO_CACHE" + +capture_one bun \ + bun install --lockfile-only --registry="$REGISTRY" + +echo "done. files:" +ls -lh "$PCAP_DIR" diff --git a/bench/pm-bench-phases.sh b/bench/pm-bench-phases.sh new file mode 100755 index 0000000000..4ca4cc4780 --- /dev/null +++ b/bench/pm-bench-phases.sh @@ -0,0 +1,460 @@ +#!/bin/bash +# Phase-isolated cold-install bench: resolve vs cold-install vs warm-link. +# utoo vs bun only (they're the PMs with a true --lockfile-only mode). +set -eo pipefail + +# --- config --- +PROJECT=${PROJECT:-ant-design} +REGISTRY=${REGISTRY:-https://registry.npmjs.org} +RUNS=${BENCH_RUNS:-3} +IFS=',' read -ra PACKAGE_MANAGERS <<< "${PM_LIST:-utoo,bun}" + +BENCH_DIR=${BENCH_DIR:-/tmp/pm-bench} +RESULTS_DIR=${RESULTS_DIR:-/tmp/pm-bench-results} +mkdir -p "$BENCH_DIR" "$RESULTS_DIR" + +# Explicit bench-scoped cache dirs so rm -rf is unambiguous and we don't +# depend on what each PM considers the default (which varies by HOME, OS +# image, and previously-set user config). +UTOO_CACHE="${UTOO_CACHE:-/tmp/utoo-bench-cache}" +UTOO_NPM_CACHE="${UTOO_NPM_CACHE:-/tmp/utoo-npm-bench-cache}" +BUN_CACHE="${BUN_CACHE:-/tmp/bun-bench-cache}" +export BUN_INSTALL_CACHE_DIR="$BUN_CACHE" + +# Drop `utoo-npm` from the PM list when no published-baseline binary is +# wired up — UTOO_NPM_BIN is set by CI's "Install utoo@npm" step. Local +# runs without it just skip the comparison instead of erroring. +if [ -z "${UTOO_NPM_BIN:-}" ]; then + FILTERED=() + for pm in "${PACKAGE_MANAGERS[@]}"; do + if [ "$pm" = "utoo-npm" ]; then + echo "skip utoo-npm: UTOO_NPM_BIN not set" >&2 + continue + fi + FILTERED+=("$pm") + done + PACKAGE_MANAGERS=("${FILTERED[@]}") +fi + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +banner() { echo -e "${YELLOW}=== $* ===${NC}"; } + +# --- metrics wrapper --- +# Keeps the hot path fast: only /usr/bin/time + counter snapshots inside the +# measured window. Disk-footprint measurement (du on node_modules / cache) +# happens once per phase AFTER hyperfine exits — see capture_footprint in +# run_phase — so 1-2s of `du` traversal doesn't bleed into wall-clock. +METRICS_WRAPPER="$RESULTS_DIR/metrics_wrapper.sh" +cat > "$METRICS_WRAPPER" <<'METRICS_EOF' +#!/bin/bash +METRICS_FILE="$1"; shift +TIME_TMP=$(mktemp) + +# --- snapshot network BEFORE the command (cheap: one read of /proc/net/dev) --- +snap_net_rx=0; snap_net_tx=0 +if [ -r /proc/net/dev ]; then + read snap_net_rx snap_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) +fi + +if [[ "$(uname)" == "Darwin" ]]; then + /usr/bin/time -l "$@" 2>"$TIME_TMP" + EXIT_CODE=$? + RSS=$(awk '/maximum resident set size/ {print $1}' "$TIME_TMP") + PG_MAJOR=$(awk '/page faults/ {print $1}' "$TIME_TMP") + PG_MINOR=$(awk '/page reclaims/ {print $1}' "$TIME_TMP") + VOL_CTX=$(awk '/ voluntary context switches/ {print $1}' "$TIME_TMP") + INVOL_CTX=$(awk '/involuntary context switches/ {print $1}' "$TIME_TMP") + USER_S=$(awk '/real/ && /user/ && /sys/ {print $3}' "$TIME_TMP") + SYS_S=$(awk '/real/ && /user/ && /sys/ {print $5}' "$TIME_TMP") +else + /usr/bin/time -v "$@" 2>"$TIME_TMP" + EXIT_CODE=$? + RSS_KB=$(awk '/Maximum resident set size/ {print $NF}' "$TIME_TMP") + RSS=$(( ${RSS_KB:-0} * 1024 )) + PG_MAJOR=$(awk '/Major \(requiring I\/O\) page faults/ {print $NF}' "$TIME_TMP") + PG_MINOR=$(awk '/Minor \(reclaiming a frame\) page faults/ {print $NF}' "$TIME_TMP") + VOL_CTX=$(awk '/Voluntary context switches/ {print $NF}' "$TIME_TMP") + INVOL_CTX=$(awk '/Involuntary context switches/ {print $NF}' "$TIME_TMP") + USER_S=$(awk -F': ' '/User time \(seconds\)/ {print $2}' "$TIME_TMP") + SYS_S=$(awk -F': ' '/System time \(seconds\)/ {print $2}' "$TIME_TMP") +fi + +# --- snapshot network AFTER, compute delta --- +net_rx=0; net_tx=0 +if [ -r /proc/net/dev ]; then + read cur_net_rx cur_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) + net_rx=$(( cur_net_rx - snap_net_rx )) + net_tx=$(( cur_net_tx - snap_net_tx )) +fi + +printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d}\n' \ + "${RSS:-0}" "${USER_S:-0}" "${SYS_S:-0}" "${PG_MAJOR:-0}" "${PG_MINOR:-0}" \ + "${VOL_CTX:-0}" "${INVOL_CTX:-0}" \ + "${net_rx:-0}" "${net_tx:-0}" >> "$METRICS_FILE" +rm -f "$TIME_TMP" +exit $EXIT_CODE +METRICS_EOF +chmod +x "$METRICS_WRAPPER" + +# Post-phase footprint helper: records final on-disk size of the paths each +# phase should have touched. Called ONCE after hyperfine finishes, so the +# (slow) du traversal is not part of any timed window. +du_bytes() { + for p in "$@"; do + if [ -e "$p" ]; then + if [[ "$(uname)" == "Darwin" ]]; then + du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}' + else + du -sb "$p" 2>/dev/null | awk '{print $1}' + fi + else + echo 0 + fi + done | awk '{s += $1} END {print s+0}' +} + +capture_footprint() { + local phase=$1 pm=$2 out=$3 + local cache + case "$pm" in + utoo) cache=$UTOO_CACHE ;; + utoo-npm) cache=$UTOO_NPM_CACHE ;; + bun) cache=$BUN_CACHE ;; + esac + printf '{"cache":%d,"node_modules":%d,"lockfile":%d}\n' \ + "$(du_bytes "$cache")" \ + "$(du_bytes "$PROJECT_DIR/node_modules")" \ + "$(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock")" \ + > "$out" +} + +# --- project --- +banner "Preparing $PROJECT" +cd "$BENCH_DIR" +if [ ! -d "$PROJECT" ]; then + git clone --depth=1 "https://github.com/ant-design/${PROJECT}.git" "$PROJECT" +fi +PROJECT_DIR="$BENCH_DIR/$PROJECT" + +# --- prepare script builder --- +# Emit a self-contained reset script that hyperfine's --prepare can re-exec. +# The script always prints what it deleted so the CI log proves prepare ran. +write_prepare() { + local path=$1 phase=$2 pm=$3 + local cache + case "$pm" in + utoo) cache=$UTOO_CACHE ;; + utoo-npm) cache=$UTOO_NPM_CACHE ;; + bun) cache=$BUN_CACHE ;; + esac + + cat > "$path" </dev/null || true + +EOF + + # Use explicit bench-scoped cache paths and rm -rf them directly — previously + # `utoo clean` / `bun pm cache rm` didn't actually empty the cache on the CI + # runner, leading to cache-hit runs masquerading as cold installs. + case "$phase" in + p0_*) + # Phase 0: full cold install — nothing reused. Lockfile + all caches wiped. + cat >> "$path" <> "$path" <> "$path" <> "$path" <> "$path" <> "$path" <> "$path" <> "$path" < "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + fi + ;; + p3_*:utoo-npm|p4_*:utoo-npm) + if [ ! -f package-lock.json ]; then + echo -e " ${CYAN}seed: running \`utoo-npm deps\` to generate package-lock.json${NC}" + "$UTOO_NPM_BIN" deps --registry="$REGISTRY" --cache-dir="$UTOO_NPM_CACHE" > "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + fi + ;; + p3_*:bun|p4_*:bun) + if [ ! -f bun.lock ]; then + echo -e " ${CYAN}seed: running \`bun install --lockfile-only\` to generate bun.lock${NC}" + rm -f package-lock.json + bun install --lockfile-only --registry="$REGISTRY" > "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + fi + ;; + esac + # Phase 4 also needs a pre-warmed cache. + if [[ "$phase" == p4_* ]]; then + local cache + case "$pm" in + utoo) cache=$UTOO_CACHE ;; + utoo-npm) cache=$UTOO_NPM_CACHE ;; + bun) cache=$BUN_CACHE ;; + esac + if [ ! -d "$cache" ] || [ -z "$(ls -A "$cache" 2>/dev/null)" ]; then + echo -e " ${CYAN}seed: warming $pm cache via full install${NC}" + rm -rf node_modules + case "$pm" in + utoo) utoo install --ignore-scripts --registry="$REGISTRY" --cache-dir="$UTOO_CACHE" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + utoo-npm) "$UTOO_NPM_BIN" install --ignore-scripts --registry="$REGISTRY" --cache-dir="$UTOO_NPM_CACHE" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + bun) bun install --ignore-scripts --registry="$REGISTRY" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + esac + rm -rf node_modules + fi + fi +} + +install_cmd() { + case "$1" in + utoo) echo "utoo install --ignore-scripts --registry=$REGISTRY --cache-dir=$UTOO_CACHE" ;; + utoo-npm) echo "$UTOO_NPM_BIN install --ignore-scripts --registry=$REGISTRY --cache-dir=$UTOO_NPM_CACHE" ;; + bun) echo "bun install --ignore-scripts --registry=$REGISTRY" ;; + esac +} + +resolve_cmd() { + case "$1" in + utoo) echo "utoo deps --registry=$REGISTRY --cache-dir=$UTOO_CACHE" ;; + utoo-npm) echo "$UTOO_NPM_BIN deps --registry=$REGISTRY --cache-dir=$UTOO_NPM_CACHE" ;; + bun) echo "bun install --lockfile-only --registry=$REGISTRY" ;; + esac +} + +run_phase() { + local phase=$1 pm=$2 cmd=$3 + local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" + local metrics="$RESULTS_DIR/${PROJECT}_${phase}_${pm}_metrics.jsonl" + local prep_script="$RESULTS_DIR/prep_${phase}_${pm}.sh" + local cmd_script="$RESULTS_DIR/cmd_${phase}_${pm}.sh" + + seed_for_phase "$phase" "$pm" + write_prepare "$prep_script" "$phase" "$pm" + + # Reset metrics file; wrap the bench command so /usr/bin/time can measure it. + # The wrapper reads PROJECT_DIR / UTOO_CACHE / BUN_CACHE from env to scope du. + : > "$metrics" + printf 'set -eo pipefail\ncd %s\n%s\n' "$PROJECT_DIR" "$cmd" > "$cmd_script" + + echo -e " ${CYAN}$pm${NC} · $phase" + export PROJECT_DIR UTOO_CACHE BUN_CACHE + if ! hyperfine \ + --runs "$RUNS" \ + --prepare "bash $prep_script" \ + --export-json "$json" \ + --show-output \ + -n "${pm}-${phase}" \ + "bash $METRICS_WRAPPER $metrics bash $cmd_script"; then + echo -e " ${RED}$pm $phase failed${NC}" + fi + + # After the timed runs: capture the state left on disk by the last run. + # Done outside hyperfine so du's traversal doesn't pollute wall-clock. + capture_footprint "$phase" "$pm" "$RESULTS_DIR/${PROJECT}_${phase}_${pm}_footprint.json" +} + +# === PHASE 0: full cold install (clean slate + full install) === +# Matches the end-to-end user scenario: no lockfile, no cache, no node_modules. +# Directly comparable to `bun install` / `utoo install` on a freshly cloned repo. +banner "Phase 0 · full cold install (lockfile + cache + node_modules all wiped)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + run_phase "p0_full_cold" "$pm" "$(install_cmd "$pm")" +done + +# === PHASE 1: resolve only (clean slate) === +banner "Phase 1 · resolve (lockfile only, cold cache)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + run_phase "p1_resolve" "$pm" "$(resolve_cmd "$pm")" +done + +# === PHASE 3: cold install (lockfile exists, cache empty) === +banner "Phase 3 · cold install (lockfile present, empty cache, empty node_modules)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + run_phase "p3_cold_install" "$pm" "$(install_cmd "$pm")" +done + +# === PHASE 4: warm link (cache populated, lockfile exists) === +banner "Phase 4 · warm link (lockfile present, populated cache, empty node_modules)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + run_phase "p4_warm_link" "$pm" "$(install_cmd "$pm")" +done + +# === SUMMARY === +banner "Summary" +RESULTS_DIR="$RESULTS_DIR" node -e " + const fs = require('fs'), path = require('path'); + const dir = process.env.RESULTS_DIR; + const order = ['p0_full_cold', 'p1_resolve', 'p3_cold_install', 'p4_warm_link']; + const timing = {}; // phase -> pm -> {mean,stddev,min,max} + const metrics = {}; // phase -> pm -> averaged resource fields + + const parseKey = (file, suffix) => { + const base = file.replace(suffix, ''); + for (const p of order) { + const idx = base.indexOf('_' + p + '_'); + if (idx === -1) continue; + const pm = base.slice(idx + p.length + 2); + return { phase: p, pm }; + } + return null; + }; + + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json') && !x.endsWith('_metrics.jsonl') && !x.endsWith('_footprint.json'))) { + const key = parseKey(f, '.json'); + if (!key) continue; + let data; try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { continue; } + const r = data.results[0]; + if (!r) continue; + (timing[key.phase] ??= {})[key.pm] = { mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }; + } + + const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx']; + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_metrics.jsonl'))) { + const key = parseKey(f, '_metrics.jsonl'); + if (!key) continue; + const rows = []; + for (const l of fs.readFileSync(path.join(dir, f), 'utf8').trim().split('\n').filter(Boolean)) { + try { rows.push(JSON.parse(l)); } catch (_) {} + } + if (rows.length === 0) continue; + const avg = {}; + for (const k of metricKeys) { + avg[k] = rows.reduce((s,e) => s + Number(e[k] || 0), 0) / rows.length; + } + (metrics[key.phase] ??= {})[key.pm] = avg; + } + + // Footprint is a post-phase single sample (not averaged): final on-disk size + // of the paths the phase should have touched. + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_footprint.json'))) { + const key = parseKey(f, '_footprint.json'); + if (!key) continue; + let data; try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { continue; } + const slot = (metrics[key.phase] ??= {})[key.pm] ??= {}; + slot.foot_cache = data.cache || 0; + slot.foot_nm = data.node_modules || 0; + slot.foot_lock = data.lockfile || 0; + } + + const pad = (s, n) => String(s).padEnd(n); + const padR = (s, n) => String(s).padStart(n); + const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(2)+'G' : b >= 1<<20 ? (b/(1<<20)).toFixed(0)+'M' : b >= 1<<10 ? (b/(1<<10)).toFixed(0)+'K' : Math.round(b)+'B'; + const fmtN = n => n >= 1e6 ? (n/1e6).toFixed(2)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(Math.round(n)); + + for (const phase of order) { + const tp = timing[phase] || {}, mp = metrics[phase] || {}; + const pms = [...new Set([...Object.keys(tp), ...Object.keys(mp)])]; + if (pms.length === 0) continue; + + console.log('\n## ' + phase); + + // Table A: wall + CPU + memory + console.log(pad('PM', 6) + ' ' + padR('wall', 8) + ' ' + padR('±σ', 7) + ' ' + padR('user', 7) + ' ' + padR('sys', 7) + ' ' + padR('RSS', 6) + ' ' + padR('pgMinor', 8)); + for (const pm of pms) { + const t = tp[pm] || {}, m = mp[pm] || {}; + console.log( + pad(pm, 6) + ' ' + + padR((t.mean ?? 0).toFixed(2)+'s', 8) + ' ' + + padR((t.stddev ?? 0).toFixed(2)+'s', 7) + ' ' + + padR((m.user_s || 0).toFixed(2)+'s', 7) + ' ' + + padR((m.sys_s || 0).toFixed(2)+'s', 7) + ' ' + + padR(m.rss ? fmtB(m.rss) : '-', 6) + ' ' + + padR(m.page_minor ? fmtN(m.page_minor) : '-', 8) + ); + } + + // Table B: context switches + network + final on-disk footprint + console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('netRX', 8) + ' ' + padR('netTX', 8) + ' ' + padR('cache', 8) + ' ' + padR('node_mod', 9) + ' ' + padR('lock', 7)); + for (const pm of pms) { + const m = mp[pm] || {}; + console.log( + pad(pm, 6) + ' ' + + padR(m.vol_ctx ? fmtN(m.vol_ctx) : '-', 8) + ' ' + + padR(m.invol_ctx ? fmtN(m.invol_ctx) : '-', 8) + ' ' + + padR(m.net_rx ? fmtB(m.net_rx) : '-', 8) + ' ' + + padR(m.net_tx ? fmtB(m.net_tx) : '-', 8) + ' ' + + padR(m.foot_cache ? fmtB(m.foot_cache) : '-', 8) + ' ' + + padR(m.foot_nm ? fmtB(m.foot_nm) : '-', 9) + ' ' + + padR(m.foot_lock ? fmtB(m.foot_lock) : '-', 7) + ); + } + } +" + +echo -e "${GREEN}Done. Raw results in $RESULTS_DIR${NC}" diff --git a/e2e/pm-bench.sh b/bench/pm-bench.sh similarity index 100% rename from e2e/pm-bench.sh rename to bench/pm-bench.sh