name: Benchmarks

on:
  workflow_dispatch:

env:
  CARGO_TERM_COLOR: always

jobs:
  # ==========================================================================
  # Phase 1: Build Rust Benchmark Binaries on ARM Runner
  # ==========================================================================
  build-rust-benchmarks:
    name: Build Rust Benchmarks
    runs-on: ubuntu-22.04-arm
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - name: Install Rust toolchain
        run: |
          rustup toolchain install stable --profile minimal

      - name: Build benchmark binaries
        run: |
          # Build all benchmark binaries in release mode
          cargo bench --no-run

          # Find and copy benchmark binaries (using -print0/xargs for robustness)
          mkdir -p benchmark-binaries
          find target/release/deps -maxdepth 1 -type f -executable -name "serialization*" \
            ! -name '*.so' ! -name '*.a' ! -name '*.d' ! -name '*.rlib' -print0 | \
            xargs -0 -I{} sh -c 'cp "$1" benchmark-binaries/ && echo "Copied: $(basename "$1")"' sh '{}'

          ls -la benchmark-binaries/

      - name: Upload benchmark binaries
        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: benchmark-binaries-aarch64
          path: benchmark-binaries/
          retention-days: 7

  # ==========================================================================
  # Phase 2: Run Rust Benchmarks on Target Hardware (NXP i.MX 8M Plus)
  # ==========================================================================
  run-rust-benchmarks:
    name: Run Rust on i.MX 8M Plus
    needs: build-rust-benchmarks
    runs-on: nxp-imx8mp-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - name: Download benchmark binaries
        uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
        with:
          name: benchmark-binaries-aarch64
          path: benchmark-binaries/

      - name: Make binaries executable
        run: chmod +x benchmark-binaries/*

      - name: Run benchmarks
        env:
          BENCH_FAST: "1"
        run: |
          mkdir -p benchmark-results

          echo "=== Running benchmarks on $(hostname) ==="
          echo "BENCH_FAST=$BENCH_FAST (reduced benchmark variants for CI)"
          uname -a
          cat /proc/cpuinfo | head -30

          # Run each benchmark binary and save output
          for bench in benchmark-binaries/*; do
            if [ -f "$bench" ] && [ -x "$bench" ]; then
              name=$(basename "$bench")
              echo ""
              echo "=== Running $name ==="

              # Run benchmark with Criterion, save both stdout and JSON
              # Use --save-baseline to generate JSON data
              "$bench" --bench --save-baseline github-ci 2>&1 | tee "benchmark-results/${name}.txt"
            fi
          done

          # Package Criterion JSON data if available
          if [ -d "target/criterion" ]; then
            echo "Packaging Criterion JSON data..."
            tar -czf benchmark-results/criterion-data.tar.gz -C target criterion
          fi

      - name: Upload raw Rust benchmark results
        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: rust-benchmark-results-raw
          path: benchmark-results/
          retention-days: 7

  # ==========================================================================
  # Phase 3: Run Python Benchmarks on Target Hardware (NXP i.MX 8M Plus)
  # ==========================================================================
  run-python-benchmarks:
    name: Run Python on i.MX 8M Plus
    runs-on: nxp-imx8mp-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - name: Set up Python virtual environment
        run: |
          # Create venv and install dependencies
          python3 -m venv venv
          source venv/bin/activate
          pip install --upgrade pip
          pip install -e ".[bench]"

      - name: Run Python benchmarks
        env:
          BENCH_FAST: "1"
        run: |
          source venv/bin/activate
          mkdir -p python-benchmark-results

          echo "=== Running Python benchmarks on $(hostname) ==="
          echo "BENCH_FAST=$BENCH_FAST (reduced benchmark variants for CI)"
          uname -a
          python3 --version

          # Run benchmarks with JSON output
          pytest benches/python/bench_serialization.py \
            --benchmark-only \
            --benchmark-json=python-benchmark-results/benchmark.json \
            --benchmark-columns=min,max,mean,stddev \
            --benchmark-disable-gc \
            -v 2>&1 | tee python-benchmark-results/benchmark.txt

      - name: Upload Python benchmark results
        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: python-benchmark-results-raw
          path: python-benchmark-results/
          retention-days: 7

  # ==========================================================================
  # Phase 4: Process and Publish Results
  # ==========================================================================
  process-benchmarks:
    name: Process Benchmark Results
    needs: [run-rust-benchmarks, run-python-benchmarks]
    runs-on: ubuntu-22.04-arm
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

      - name: Download Rust benchmark results
        uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
        with:
          name: rust-benchmark-results-raw
          path: rust-benchmark-results/

      - name: Download Python benchmark results
        uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
        with:
          name: python-benchmark-results-raw
          path: python-benchmark-results/

      - name: Parse and format benchmark results
        id: bench
        run: |
          # Combine Rust text output for the summary
          cat rust-benchmark-results/*.txt > rust-benchmark-output.txt 2>/dev/null || true

          # Copy Python benchmark output
          cp python-benchmark-results/benchmark.txt python-benchmark-output.txt 2>/dev/null || true

          # Combine both for full output
          echo "=== Rust Benchmarks ===" > benchmark-output.txt
          cat rust-benchmark-output.txt >> benchmark-output.txt 2>/dev/null || echo "No Rust benchmarks" >> benchmark-output.txt
          echo "" >> benchmark-output.txt
          echo "=== Python Benchmarks ===" >> benchmark-output.txt
          cat python-benchmark-output.txt >> benchmark-output.txt 2>/dev/null || echo "No Python benchmarks" >> benchmark-output.txt

          # Extract Criterion JSON data if available
          if [ -f rust-benchmark-results/criterion-data.tar.gz ]; then
            echo "Extracting Criterion JSON data..."
            tar -xzf rust-benchmark-results/criterion-data.tar.gz
          fi

          # Parse Criterion JSON for reliable benchmark data
          python3 << 'PARSE_SCRIPT'
          import json
          import os
          import glob

          rust_benchmarks = []
          python_benchmarks = []

          # Parse Criterion JSON files for Rust benchmarks
          criterion_dir = 'criterion'
          if os.path.isdir(criterion_dir):
              print("Using Criterion JSON data for Rust benchmarks")

              # Find all benchmark.json files in 'new' directories
              for bench_json in glob.glob(f'{criterion_dir}/**/new/benchmark.json', recursive=True):
                  bench_dir = os.path.dirname(bench_json)
                  estimates_json = os.path.join(bench_dir, 'estimates.json')

                  if not os.path.exists(estimates_json):
                      continue

                  try:
                      with open(bench_json) as f:
                          bench_data = json.load(f)
                      with open(estimates_json) as f:
                          estimates = json.load(f)

                      # Get full benchmark name from benchmark.json
                      full_id = bench_data.get('full_id', '')
                      if not full_id:
                          continue

                      # Get median time in nanoseconds from estimates.json
                      # Use 'slope' if available and not null (more accurate), else 'median'
                      time_data = estimates.get('slope')
                      if time_data is None:
                          time_data = estimates.get('median', {})
                      point_estimate = time_data.get('point_estimate')

                      if point_estimate is not None:
                          # Convert from nanoseconds to appropriate unit
                          ns = float(point_estimate)
                          if ns >= 1e9:
                              time_str = f"{ns/1e9:.4f} s"
                          elif ns >= 1e6:
                              time_str = f"{ns/1e6:.4f} ms"
                          elif ns >= 1e3:
                              time_str = f"{ns/1e3:.4f} µs"
                          else:
                              time_str = f"{ns:.4f} ns"

                          # Get throughput if available
                          throughput = bench_data.get('throughput', {})
                          thrpt_str = 'N/A'
                          if throughput:
                              # Calculate throughput from time and bytes/elements
                              bytes_per_iter = throughput.get('Bytes')
                              if bytes_per_iter and ns > 0:
                                  bytes_per_sec = bytes_per_iter * 1e9 / ns
                                  if bytes_per_sec >= 1e9:
                                      thrpt_str = f"{bytes_per_sec/1e9:.2f} GiB/s"
                                  elif bytes_per_sec >= 1e6:
                                      thrpt_str = f"{bytes_per_sec/1e6:.2f} MiB/s"
                                  else:
                                      thrpt_str = f"{bytes_per_sec/1e3:.2f} KiB/s"

                          rust_benchmarks.append({
                              'name': full_id,
                              'time': time_str,
                              'throughput': thrpt_str
                          })
                  except Exception as e:
                      print(f"Warning: Failed to parse {bench_json}: {e}")

          # Parse Python benchmark JSON
          python_json = 'python-benchmark-results/benchmark.json'
          if os.path.exists(python_json):
              print("Parsing Python benchmark JSON")
              try:
                  with open(python_json) as f:
                      py_data = json.load(f)

                  for bench in py_data.get('benchmarks', []):
                      name = bench.get('name', '')
                      stats = bench.get('stats', {})
                      mean_ns = stats.get('mean', 0) * 1e9  # Convert seconds to nanoseconds

                      if mean_ns >= 1e9:
                          time_str = f"{mean_ns/1e9:.4f} s"
                      elif mean_ns >= 1e6:
                          time_str = f"{mean_ns/1e6:.4f} ms"
                      elif mean_ns >= 1e3:
                          time_str = f"{mean_ns/1e3:.4f} µs"
                      else:
                          time_str = f"{mean_ns:.4f} ns"

                      python_benchmarks.append({
                          'name': name,
                          'time': time_str,
                          'throughput': 'N/A'
                      })
              except Exception as e:
                  print(f"Warning: Failed to parse Python benchmarks: {e}")

          # Write combined JSON
          with open('benchmarks.json', 'w') as f:
              json.dump({
                  'rust': rust_benchmarks,
                  'python': python_benchmarks
              }, f, indent=2)

          print(f"Parsed {len(rust_benchmarks)} Rust benchmarks, {len(python_benchmarks)} Python benchmarks")
          PARSE_SCRIPT

          # Generate markdown summary from JSON
          python3 << 'GENERATE_SCRIPT'
          import json
          import re

          with open('benchmarks.json', 'r') as f:
              data = json.load(f)

          # Support both old format (list) and new format (dict with rust/python)
          if isinstance(data, dict):
              rust_benchmarks = data.get('rust', [])
              python_benchmarks = data.get('python', [])
          else:
              rust_benchmarks = data
              python_benchmarks = []

          # Human-readable size labels (use ASCII 'x' to avoid UTF-8 encoding issues in Mermaid)
          SIZE_LABELS = {
              # Image: resolution + encoding
              'VGA_rgb8': 'VGA RGB',
              'VGA_yuyv': 'VGA YUYV',
              'VGA_nv12': 'VGA NV12',
              'HD_rgb8': 'HD RGB',
              'HD_yuyv': 'HD YUYV',
              'HD_nv12': 'HD NV12',
              'FHD_rgb8': 'FHD RGB',
              'FHD_yuyv': 'FHD YUYV',
              'FHD_nv12': 'FHD NV12',
              # DmaBuf: resolution (zero-copy reference)
              # PointCloud2: point count
              'sparse_1K': '1K points',
              'medium_10K': '10K points',
              'dense_65K': '65K points',
              'very_dense_131K': '131K points',
              # Mask: resolution x classes
              '320x320_8class': '320x320 8 classes',
              '320x320_32class': '320x320 32 classes',
              '640x640_8class': '640x640 8 classes',
              '640x640_32class': '640x640 32 classes',
              '1280x1280_8class': '1280x1280 8 classes',
              '1280x1280_32class': '1280x1280 32 classes',
              # RadarCube: SmartMicro DRVEGRD configurations
              # DRVEGRD-169: 4D/UHD Corner Radar (77-81 GHz)
              'DRVEGRD169_ultra_short': 'DRVEGRD-169 Ultra-Short',
              'DRVEGRD169_short': 'DRVEGRD-169 Short',
              'DRVEGRD169_medium': 'DRVEGRD-169 Medium',
              'DRVEGRD169_long': 'DRVEGRD-169 Long',
              # DRVEGRD-171: 4D/PXHD Front Radar (76-77 GHz)
              'DRVEGRD171_short': 'DRVEGRD-171 Short',
              'DRVEGRD171_medium': 'DRVEGRD-171 Medium',
              'DRVEGRD171_long': 'DRVEGRD-171 Long',
              # CompressedVideo: payload size
              '10KB': '10 KB',
              '100KB': '100 KB',
              '500KB': '500 KB',
              '1MB': '1 MB',
          }

          # Sort order for variants (by approximate size)
          SIZE_ORDER = {
              # Image: by resolution then encoding
              'VGA_rgb8': 1, 'VGA_yuyv': 2, 'VGA_nv12': 3,
              'HD_rgb8': 4, 'HD_yuyv': 5, 'HD_nv12': 6,
              'FHD_rgb8': 7, 'FHD_yuyv': 8, 'FHD_nv12': 9,
              # PointCloud2
              'sparse_1K': 1, 'medium_10K': 2, 'dense_65K': 3, 'very_dense_131K': 4,
              # Mask
              '320x320_8class': 1, '320x320_32class': 2,
              '640x640_8class': 3, '640x640_32class': 4,
              '1280x1280_8class': 5, '1280x1280_32class': 6,
              # RadarCube: by range mode (DRVEGRD-169 corner, then DRVEGRD-171 front)
              'DRVEGRD169_ultra_short': 1, 'DRVEGRD169_short': 2,
              'DRVEGRD169_medium': 3, 'DRVEGRD169_long': 4,
              'DRVEGRD171_short': 5, 'DRVEGRD171_medium': 6, 'DRVEGRD171_long': 7,
              # CompressedVideo
              '10KB': 1, '100KB': 2, '500KB': 3, '1MB': 4,
          }

          def parse_time_to_ms(time_str):
              """Convert time string to milliseconds."""
              if time_str == 'N/A':
                  return None
              match = re.match(r'([\d.]+)\s*(\w+)', time_str)
              if not match:
                  return None
              val, unit = float(match.group(1)), match.group(2)
              multipliers = {'ns': 0.000001, 'µs': 0.001, 'us': 0.001, 'ms': 1, 's': 1000}
              return val * multipliers.get(unit, 1)

          def parse_time_to_ns(time_str):
              """Convert time string to nanoseconds."""
              if time_str == 'N/A':
                  return None
              match = re.match(r'([\d.]+)\s*(\w+)', time_str)
              if not match:
                  return None
              val, unit = float(match.group(1)), match.group(2)
              multipliers = {'ns': 1, 'µs': 1000, 'us': 1000, 'ms': 1000000, 's': 1000000000}
              return val * multipliers.get(unit, 1)

          # Categorize Rust benchmarks
          basic_msgs = []
          heavy_msgs = []

          for b in rust_benchmarks:
              name = b['name']
              if any(cat in name for cat in ['builtin_interfaces', 'std_msgs', 'geometry_msgs']):
                  basic_msgs.append(b)
              else:
                  heavy_msgs.append(b)

          # Heavy message categories with metadata
          heavy_categories = {
              'DmaBuf': {
                  'benchmarks': [],
                  'desc': 'Zero-copy DMA buffer reference (metadata only)',
                  'size_axis': 'Resolution',
                  'unit': 'ns',  # Nanoseconds - very fast
              },
              'Image': {
                  'benchmarks': [],
                  'desc': 'Camera frame serialization (RGB/YUYV/NV12)',
                  'size_axis': 'Format',
                  'unit': 'ms',
              },
              'PointCloud2': {
                  'benchmarks': [],
                  'desc': 'LiDAR point cloud data',
                  'size_axis': 'Point Count',
                  'unit': 'ms',
              },
              'Mask': {
                  'benchmarks': [],
                  'desc': 'Segmentation mask data (uncompressed)',
                  'size_axis': 'Size',
                  'unit': 'ms',
              },
              'CompressedMask': {
                  'benchmarks': [],
                  'desc': 'Segmentation mask data (zstd compressed)',
                  'size_axis': 'Size',
                  'unit': 'ms',
              },
              'RadarCube': {
                  'benchmarks': [],
                  'desc': 'SmartMicro DRVEGRD radar cube tensors',
                  'size_axis': 'Mode',
                  'unit': 'ms',
              },
              'FoxgloveCompressedVideo': {
                  'benchmarks': [],
                  'desc': 'Compressed video frames',
                  'size_axis': 'Payload Size',
                  'unit': 'ms',
              },
          }

          for b in heavy_msgs:
              for cat in heavy_categories:
                  if b['name'].startswith(cat + '/'):
                      heavy_categories[cat]['benchmarks'].append(b)
                      break

          with open('benchmark-summary.md', 'w') as f:
              f.write("## 📊 On-Target Benchmark Results\n\n")
              f.write("**Target Hardware:** NXP i.MX 8M Plus (Cortex-A53 @ 1.8GHz)\n")
              f.write("**Architecture:** aarch64\n")
              total = len(rust_benchmarks) + len(python_benchmarks)
              f.write(f"**Total Benchmarks:** {total} (Rust: {len(rust_benchmarks)}, Python: {len(python_benchmarks)})\n\n")

              # Rust Heavy Message Benchmarks
              if rust_benchmarks:
                  f.write("## 🦀 Rust Benchmarks\n\n")

              for cat_name, cat_info in heavy_categories.items():
                  cat_benchmarks = cat_info['benchmarks']
                  if not cat_benchmarks:
                      continue

                  f.write(f"### {cat_name}\n\n")
                  f.write(f"*{cat_info['desc']}*\n\n")

                  # Group by variant
                  variants = {}
                  for b in cat_benchmarks:
                      parts = b['name'].split('/')
                      if len(parts) >= 3:
                          op = parts[1]
                          variant = parts[2]
                      else:
                          op = parts[1] if len(parts) > 1 else 'unknown'
                          variant = 'default'
                      if variant not in variants:
                          variants[variant] = {'serialize': 'N/A', 'deserialize': 'N/A'}
                      variants[variant][op] = b['time']

                  # Sort variants by size order
                  sorted_variants = sorted(variants.items(),
                      key=lambda x: SIZE_ORDER.get(x[0], 99))

                  # Table
                  f.write(f"| {cat_info['size_axis']} | Serialize | Deserialize |\n")
                  f.write("|------------|-----------|-------------|\n")

                  chart_labels = []
                  ser_times = []
                  deser_times = []

                  # Determine unit for this category
                  chart_unit = cat_info.get('unit', 'ms')
                  parse_fn = parse_time_to_ns if chart_unit == 'ns' else parse_time_to_ms

                  for variant, data in sorted_variants:
                      label = SIZE_LABELS.get(variant, variant)
                      f.write(f"| {label} | {data['serialize']} | {data['deserialize']} |\n")

                      # Collect for chart (in appropriate unit)
                      ser_val = parse_fn(data['serialize'])
                      deser_val = parse_fn(data['deserialize'])

                      chart_labels.append(label)
                      # Round to 2 decimal places for chart display
                      ser_times.append(round(ser_val, 2) if ser_val else 0)
                      deser_times.append(round(deser_val, 2) if deser_val else 0)

                  f.write("\n")

                  # Generate QuickChart URL for grouped horizontal bar chart
                  # Using Chart.js config via QuickChart API
                  import urllib.parse

                  chart_config = {
                      "type": "horizontalBar",
                      "data": {
                          "labels": chart_labels,
                          "datasets": [
                              {
                                  "label": "Serialize",
                                  "data": ser_times,
                                  "backgroundColor": "rgba(54, 162, 235, 0.8)",
                                  "borderColor": "rgba(54, 162, 235, 1)",
                                  "borderWidth": 1
                              },
                              {
                                  "label": "Deserialize",
                                  "data": deser_times,
                                  "backgroundColor": "rgba(255, 99, 132, 0.8)",
                                  "borderColor": "rgba(255, 99, 132, 1)",
                                  "borderWidth": 1
                              }
                          ]
                      },
                      "options": {
                          "title": {
                              "display": True,
                              "text": f"{cat_name} Latency ({chart_unit})"
                          },
                          "scales": {
                              "xAxes": [{
                                  "ticks": {"beginAtZero": True},
                                  "scaleLabel": {"display": True, "labelString": f"Time ({chart_unit})"}
                              }]
                          },
                          "plugins": {
                              "datalabels": {
                                  "display": True,
                                  "anchor": "end",
                                  "align": "end",
                                  "formatter": "(value) => value > 0 ? value.toFixed(2) : ''"
                              }
                          }
                      }
                  }

                  chart_json = json.dumps(chart_config, separators=(',', ':'))
                  chart_url = f"https://quickchart.io/chart?c={urllib.parse.quote(chart_json)}&w=600&h={max(200, len(chart_labels) * 50)}"
                  f.write(f"![{cat_name} Chart]({chart_url})\n\n")

              # Basic message types summary (collapsed)
              f.write("<details>\n")
              f.write("<summary>Rust Basic Message Types (click to expand)</summary>\n\n")
              f.write("| Message | Serialize | Deserialize |\n")
              f.write("|---------|-----------|-------------|\n")

              msg_data = {}
              for b in basic_msgs:
                  parts = b['name'].split('/')
                  if len(parts) >= 3:
                      msg_type = f"{parts[0]}/{parts[1]}"
                      op = parts[2]
                      if msg_type not in msg_data:
                          msg_data[msg_type] = {'serialize': 'N/A', 'deserialize': 'N/A'}
                      msg_data[msg_type][op] = b['time']

              for msg_type in sorted(msg_data.keys()):
                  data = msg_data[msg_type]
                  f.write(f"| {msg_type} | {data['serialize']} | {data['deserialize']} |\n")

              f.write("\n</details>\n\n")

              # Rust Summary statistics
              f.write("### Rust Summary\n\n")
              f.write(f"- **Heavy message benchmarks:** {len(heavy_msgs)}\n")
              f.write(f"- **Basic message benchmarks:** {len(basic_msgs)}\n")
              f.write(f"- **Total Rust:** {len(rust_benchmarks)}\n\n")

              # Python Benchmarks Section
              if python_benchmarks:
                  f.write("## 🐍 Python Benchmarks\n\n")

                  # Group Python benchmarks by category
                  py_basic = []
                  py_heavy = []
                  for b in python_benchmarks:
                      name = b['name'].lower()
                      if any(cat in name for cat in ['time', 'duration', 'header', 'color', 'vector', 'point', 'quaternion', 'pose', 'transform', 'twist']):
                          py_basic.append(b)
                      else:
                          py_heavy.append(b)

                  # Heavy benchmarks table
                  if py_heavy:
                      f.write("### Heavy Message Types\n\n")
                      f.write("| Benchmark | Time |\n")
                      f.write("|-----------|------|\n")
                      for b in sorted(py_heavy, key=lambda x: x['name']):
                          f.write(f"| {b['name']} | {b['time']} |\n")
                      f.write("\n")

                  # Basic benchmarks (collapsed)
                  if py_basic:
                      f.write("<details>\n")
                      f.write("<summary>Python Basic Message Types (click to expand)</summary>\n\n")
                      f.write("| Benchmark | Time |\n")
                      f.write("|-----------|------|\n")
                      for b in sorted(py_basic, key=lambda x: x['name']):
                          f.write(f"| {b['name']} | {b['time']} |\n")
                      f.write("\n</details>\n\n")

                  f.write("### Python Summary\n\n")
                  f.write(f"- **Heavy message benchmarks:** {len(py_heavy)}\n")
                  f.write(f"- **Basic message benchmarks:** {len(py_basic)}\n")
                  f.write(f"- **Total Python:** {len(python_benchmarks)}\n")

          print("Generated benchmark-summary.md")
          GENERATE_SCRIPT

          # Count benchmarks for output
          BENCH_COUNT=$(grep -c "Benchmarking\|PASSED" benchmark-output.txt 2>/dev/null || echo "0")
          echo "bench_count=$BENCH_COUNT" >> $GITHUB_OUTPUT

      - name: Write job summary
        run: |
          cat benchmark-summary.md >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "---" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "<details>" >> $GITHUB_STEP_SUMMARY
          echo "<summary>📋 Full Benchmark Output</summary>" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          cat benchmark-output.txt >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          echo "</details>" >> $GITHUB_STEP_SUMMARY

      - name: Upload processed results
        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: benchmark-results
          path: |
            benchmark-output.txt
            benchmark-summary.md
            benchmarks.json
          retention-days: 30
