VYPR
Medium severity4.1NVD Advisory· Published Mar 26, 2026· Updated Apr 22, 2026

CVE-2026-33619

CVE-2026-33619

Description

PinchTab is a standalone HTTP server that gives AI agents direct control over a Chrome browser. PinchTab v0.8.3 contains a server-side request forgery issue in the optional scheduler's webhook delivery path. When a task is submitted to POST /tasks with a user-controlled callbackUrl, the v0.8.3 scheduler sends an outbound HTTP POST to that URL when the task reaches a terminal state. In that release, the webhook path validated only the URL scheme and did not reject loopback, private, link-local, or other non-public destinations. Because the v0.8.3 implementation also used the default HTTP client behavior, redirects were followed and the destination was not pinned to validated IPs. This allowed blind SSRF from the PinchTab server to attacker-chosen HTTP(S) targets reachable from the server. This issue is narrower than a general unauthenticated internet-facing SSRF. The scheduler is optional and off by default, and in token-protected deployments the attacker must already be able to submit tasks using the server's master API token. In PinchTab's intended deployment model, that token represents administrative control rather than a low-privilege role. Tokenless deployments lower the barrier further, but that is a separate insecure configuration state rather than impact created by the webhook bug itself. PinchTab's default deployment model is local-first and user-controlled, with loopback bind and token-based access in the recommended setup. That lowers practical risk in default use, even though it does not remove the underlying webhook issue when the scheduler is enabled and reachable. This was addressed in v0.8.4 by validating callback targets before dispatch, rejecting non-public IP ranges, pinning delivery to validated IPs, disabling redirect following, and validating callbackUrl during task submission.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/pinchtab/pinchtabGo
< 0.8.40.8.4

Affected products

1

Patches

1
c824574c3a05

fix: Docker image Chrome flag parsing + CI reusable workflows + download SSRF hardening (#314)

https://github.com/pinchtab/pinchtabluigiagentMar 17, 2026via ghsa
22 files changed · +1015 937
  • cmd/pinchtab/cmd_config.go+5 2 modified
    @@ -64,14 +64,17 @@ func init() {
     			handleConfigGet(args[0])
     		},
     	})
    -	configCmd.AddCommand(&cobra.Command{
    +	configSetCmd := &cobra.Command{
     		Use:   "set <path> <val>",
     		Short: "Set a config value (e.g., server.port 8080)",
     		Args:  cobra.ExactArgs(2),
     		Run: func(cmd *cobra.Command, args []string) {
     			handleConfigSet(args[0], args[1])
     		},
    -	})
    +	}
    +	// Allow values like "--no-sandbox --disable-gpu" after the config path.
    +	configSetCmd.Flags().SetInterspersed(false)
    +	configCmd.AddCommand(configSetCmd)
     	configCmd.AddCommand(&cobra.Command{
     		Use:   "patch <json>",
     		Short: "Merge JSON into config",
    
  • cmd/pinchtab/cmd_config_test.go+34 0 modified
    @@ -1,6 +1,7 @@
     package main
     
     import (
    +	"path/filepath"
     	"strings"
     	"testing"
     
    @@ -48,3 +49,36 @@ func TestClipboardCommands(t *testing.T) {
     		}
     	}
     }
    +
    +func TestConfigSetAllowsDashPrefixedValue(t *testing.T) {
    +	configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
    +	t.Setenv("PINCHTAB_CONFIG", configPath)
    +
    +	fc := config.DefaultFileConfig()
    +	if err := config.SaveFileConfig(&fc, configPath); err != nil {
    +		t.Fatalf("SaveFileConfig() error = %v", err)
    +	}
    +
    +	t.Cleanup(func() {
    +		rootCmd.SetArgs(nil)
    +	})
    +
    +	output := captureStdout(t, func() {
    +		rootCmd.SetArgs([]string{"config", "set", "browser.extraFlags", "--no-sandbox --disable-gpu"})
    +		if err := rootCmd.Execute(); err != nil {
    +			t.Fatalf("Execute() error = %v", err)
    +		}
    +	})
    +
    +	if !strings.Contains(output, "Set browser.extraFlags = --no-sandbox --disable-gpu") {
    +		t.Fatalf("expected success output, got %q", output)
    +	}
    +
    +	saved, _, err := config.LoadFileConfig()
    +	if err != nil {
    +		t.Fatalf("LoadFileConfig() error = %v", err)
    +	}
    +	if saved.Browser.ChromeExtraFlags != "--no-sandbox --disable-gpu" {
    +		t.Fatalf("ChromeExtraFlags = %q, want %q", saved.Browser.ChromeExtraFlags, "--no-sandbox --disable-gpu")
    +	}
    +}
    
  • dev+22 1 modified
    @@ -24,6 +24,7 @@ COMMANDS=(
       "test unit:🔬:Go unit tests"
       "test dashboard:🔬:Dashboard unit tests"
       "e2e:🐳:E2E release suites"
    +  "  docker:🐳:Build local image and run Docker smoke test"
       "  pr:🐳:E2E PR suite"
       "  recent:🐳:E2E Recent tests"
       "  api-fast:🐳:E2E API fast tests"
    @@ -131,6 +132,26 @@ run_command() {
           echo ""
           exec bash scripts/test.sh system
           ;;
    +    "e2e docker")
    +      local platform="${DOCKER_DEFAULT_PLATFORM:-}"
    +      if [ -z "$platform" ]; then
    +        platform="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}')"
    +      fi
    +      echo "  ${ACCENT}${BOLD}🏗️ Build local Docker image${NC}"
    +      echo ""
    +      echo "  ${MUTED}platform: ${platform}${NC}"
    +      docker buildx build --load --platform "$platform" -t pinchtab-local:test .
    +      echo ""
    +      echo "  ${ACCENT}${BOLD}💨 Docker smoke test${NC}"
    +      echo ""
    +      exec bash scripts/docker-smoke.sh pinchtab-local:test
    +      ;;
    +    "e2e docker "*)
    +      local image="${target#e2e docker }"
    +      echo "  ${ACCENT}${BOLD}💨 Docker smoke test${NC}"
    +      echo ""
    +      exec bash scripts/docker-smoke.sh "$image"
    +      ;;
         "e2e"*)
           local suite="release"
           if [ "$target" != "e2e" ]; then
    @@ -257,7 +278,7 @@ if [ $# -gt 0 ]; then
           ;;
         e2e)
           if [ $# -gt 1 ]; then
    -        run_command "e2e $2"
    +        run_command "e2e ${*:2}"
           else
             run_command "e2e"
           fi
    
  • docs/guides/contributing.md+17 6 modified
    @@ -238,7 +238,8 @@ go test ./...                              # Unit tests only
     go test ./... -v                           # Verbose
     go test ./... -v -coverprofile=coverage.out
     go tool cover -html=coverage.out           # View coverage
    -./dev e2e                                 # Run E2E tests (curl + CLI)
    +./dev e2e                                 # Run the default E2E release suite
    +./dev e2e docker                          # Build the local image and run Docker smoke
     ```
     
     ### Developer Toolkit (`dev`)
    @@ -264,13 +265,23 @@ All dev scripts are accessible through `./dev`:
     | `check security` | Gosec security scan |
     | `check docs` | Validate docs JSON |
     | `format dashboard` | Run Prettier on dashboard sources |
    -| `test` | Unit + E2E tests |
    +| `test` | Run all tests |
     | `test unit` | Unit tests only |
    -| `e2e` | E2E tests (curl + CLI) |
    -| `e2e curl` | E2E curl tests only |
    -| `e2e cli` | E2E CLI tests only |
    -| `build` | Build & run (default) |
    +| `test dashboard` | Dashboard tests only |
    +| `e2e` | Run the default E2E release suite |
    +| `e2e docker` | Build the local image and run the Docker smoke test |
    +| `e2e pr` | Run the PR E2E suite |
    +| `e2e recent` | Run the recent E2E suite |
    +| `e2e api-fast` | Run the fast API E2E suite |
    +| `e2e cli-fast` | Run the fast CLI E2E suite |
    +| `e2e full-api` | Run the full API E2E suite |
    +| `e2e full-cli` | Run the full CLI E2E suite |
    +| `e2e full-extended` | Run the extended E2E suite |
    +| `e2e release` | Run the release E2E meta-suite |
    +| `build` | Build the application |
    +| `dev` | Build and run the application |
     | `run` | Run the application |
    +| `binary` | Build the local release-style binary |
     | `doctor` | Setup dev environment |
     
     For the fancy interactive picker, install [gum](https://github.com/charmbracelet/gum): `brew install gum`
    
  • .github/workflows/dashboard.yml+7 82 modified
    @@ -8,12 +8,14 @@ on:
           - 'internal/api/types/**'
           - 'scripts/build-dashboard.sh'
           - '.github/workflows/dashboard.yml'
    +      - '.github/workflows/reusable-dashboard.yml'
       pull_request:
         paths:
           - 'dashboard/**'
           - 'internal/api/types/**'
           - 'scripts/build-dashboard.sh'
           - '.github/workflows/dashboard.yml'
    +      - '.github/workflows/reusable-dashboard.yml'
       workflow_dispatch:
     
     env:
    @@ -22,86 +24,9 @@ env:
     permissions:
       contents: read
     
    -defaults:
    -  run:
    -    working-directory: dashboard
    -
     jobs:
    -  check:
    -    name: Lint, Type Check & Test
    -    runs-on: ubuntu-latest
    -    if: github.event.pull_request.draft == false
    -    steps:
    -      - uses: actions/checkout@v5
    -
    -      - name: Setup Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version: '1.26'
    -
    -      - name: Setup Bun
    -        uses: oven-sh/setup-bun@v2
    -        with:
    -          bun-version: latest
    -
    -      - name: Install tygo
    -        run: go install github.com/gzuidhof/tygo@latest
    -
    -      - name: Install dependencies
    -        run: bun install --frozen-lockfile
    -
    -      - name: Generate dashboard types
    -        run: |
    -          $HOME/go/bin/tygo generate
    -          npx prettier --write src/generated/types.ts
    -          git diff --exit-code -- src/generated/types.ts
    -
    -      - name: TypeScript check
    -        run: bun run typecheck
    -
    -      - name: ESLint
    -        run: bun run lint
    -
    -      - name: Prettier check
    -        run: bun run format:check
    -
    -      - name: Run tests
    -        run: bun run test:run
    -
    -  build:
    -    name: Build
    -    runs-on: ubuntu-latest
    -    needs: check
    -    steps:
    -      - uses: actions/checkout@v5
    -
    -      - name: Setup Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version: '1.26'
    -
    -      - name: Setup Bun
    -        uses: oven-sh/setup-bun@v2
    -        with:
    -          bun-version: latest
    -
    -      - name: Install tygo
    -        run: go install github.com/gzuidhof/tygo@latest
    -
    -      - name: Install dependencies
    -        run: bun install --frozen-lockfile
    -
    -      - name: Generate dashboard types
    -        run: |
    -          $HOME/go/bin/tygo generate
    -          npx prettier --write src/generated/types.ts
    -
    -      - name: Build
    -        run: bun run build
    -
    -      - name: Upload build artifacts
    -        uses: actions/upload-artifact@v4
    -        with:
    -          name: dashboard-dist
    -          path: dashboard/dist/
    -          retention-days: 7
    +  dashboard:
    +    if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
    +    uses: ./.github/workflows/reusable-dashboard.yml
    +    with:
    +      ref: ${{ github.sha }}
    
  • .github/workflows/docs-verify.yml+8 12 modified
    @@ -6,11 +6,14 @@ on:
         paths:
           - 'docs/**'
           - '.github/workflows/docs-verify.yml'
    +      - '.github/workflows/reusable-docs-verify.yml'
       pull_request:
         branches: [main]
         paths:
           - 'docs/**'
           - '.github/workflows/docs-verify.yml'
    +      - '.github/workflows/reusable-docs-verify.yml'
    +  workflow_dispatch:
     
     env:
       FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    @@ -19,15 +22,8 @@ permissions:
       contents: read
     
     jobs:
    -  docs-validate:
    -    name: Validate Documentation
    -    runs-on: ubuntu-latest
    -    if: github.event.pull_request.draft == false
    -    steps:
    -      - uses: actions/checkout@v5
    -
    -      - name: Check docs.json references
    -        run: |
    -          echo "🔍 Validating docs.json..."
    -          ./scripts/check-docs-json.sh
    -        shell: bash
    +  docs:
    +    if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
    +    uses: ./.github/workflows/reusable-docs-verify.yml
    +    with:
    +      ref: ${{ github.sha }}
    
  • .github/workflows/e2e-recent.yml+43 385 modified
    @@ -29,406 +29,64 @@ env:
     
     jobs:
       recent:
    -    name: E2E Recent Tests
    -    runs-on: ubuntu-latest
         if: github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
    -    timeout-minutes: 10
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run recent E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e recent 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Dump pinchtab logs on failure
    -        if: failure()
    -        run: |
    -          echo "==== pinchtab logs ===="
    -          docker compose -f tests/e2e/docker-compose.yml logs pinchtab || true
    -          echo ""
    -          echo "==== filtered Chrome/extension lines ===="
    -          docker compose -f tests/e2e/docker-compose.yml logs pinchtab 2>/dev/null | grep -Ei 'chrome|extension|devtools|load-extension|disable-extensions|headless|warning|error' || true
    -          echo ""
    -          echo "==== instance inventory ===="
    -          INSTANCES_JSON=$(curl -sf http://localhost:9999/instances || true)
    -          if [ -n "$INSTANCES_JSON" ]; then
    -            echo "$INSTANCES_JSON"
    -            if command -v jq >/dev/null 2>&1; then
    -              echo "$INSTANCES_JSON" | jq -r '.[].id' | while read -r inst_id; do
    -                [ -z "$inst_id" ] && continue
    -                echo ""
    -                echo "==== logs for $inst_id ===="
    -                curl -sf "http://localhost:9999/instances/$inst_id/logs" || true
    -              done
    -            fi
    -          else
    -            echo "unable to fetch /instances from localhost:9999"
    -          fi
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E Recent Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E Recent Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: recent
    +      summary_title: E2E Recent Tests
    +      timeout_minutes: 10
    +      compose_file: tests/e2e/docker-compose.yml
    +      dump_instance_inventory: true
     
       api-fast:
    -    name: E2E API Fast Tests
         needs: recent
    -    runs-on: ubuntu-latest
         if: github.event_name != 'workflow_dispatch'
    -    timeout-minutes: 15
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run API fast E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e api-fast 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E API Fast Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E API Fast Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: api-fast
    +      summary_title: E2E API Fast Tests
    +      timeout_minutes: 15
    +      compose_file: tests/e2e/docker-compose.yml
     
       cli-fast:
    -    name: E2E CLI Fast Tests
         needs: recent
    -    runs-on: ubuntu-latest
         if: github.event_name != 'workflow_dispatch'
    -    timeout-minutes: 15
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run CLI fast E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e cli-fast 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E CLI Fast Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E CLI Fast Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: cli-fast
    +      summary_title: E2E CLI Fast Tests
    +      timeout_minutes: 15
    +      compose_file: tests/e2e/docker-compose-cli.yml
     
       full-api:
    -    name: E2E Full API Tests
    -    runs-on: ubuntu-latest
         if: github.event_name == 'workflow_dispatch'
    -    timeout-minutes: 20
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run full API E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e full-api 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E Full API Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E Full API Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: full-api
    +      summary_title: E2E Full API Tests
    +      timeout_minutes: 20
    +      compose_file: tests/e2e/docker-compose.yml
     
       full-cli:
    -    name: E2E Full CLI Tests
    -    runs-on: ubuntu-latest
         if: github.event_name == 'workflow_dispatch'
    -    timeout-minutes: 20
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run full CLI E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e full-cli 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E Full CLI Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E Full CLI Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: full-cli
    +      summary_title: E2E Full CLI Tests
    +      timeout_minutes: 20
    +      compose_file: tests/e2e/docker-compose-cli.yml
     
       full-extended:
    -    name: E2E Full Extended Tests
    -    runs-on: ubuntu-latest
         if: github.event_name == 'workflow_dispatch'
    -    timeout-minutes: 20
    -
    -    steps:
    -      - name: Checkout
    -        uses: actions/checkout@v5
    -
    -      - name: Set up Go
    -        uses: actions/setup-go@v6
    -        with:
    -          go-version-file: go.mod
    -          cache: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@v3
    -
    -      - name: Run full extended E2E tests
    -        id: e2e
    -        run: |
    -          set +e
    -          mkdir -p tests/e2e/results
    -
    -          ./dev e2e full-extended 2>&1 | tee e2e-output.log
    -          EXIT_CODE=${PIPESTATUS[0]}
    -
    -          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    -          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    -
    -          echo "passed=$PASSED" >> $GITHUB_OUTPUT
    -          echo "failed=$FAILED" >> $GITHUB_OUTPUT
    -          {
    -            echo 'failures<<EOF'
    -            echo "$FAILURES"
    -            echo 'EOF'
    -          } >> $GITHUB_OUTPUT
    -
    -          exit $EXIT_CODE
    -        env:
    -          DOCKER_BUILDKIT: 1
    -          COMPOSE_DOCKER_CLI_BUILD: 1
    -
    -      - name: Post summary
    -        if: always()
    -        run: |
    -          PASSED="${{ steps.e2e.outputs.passed }}"
    -          FAILED="${{ steps.e2e.outputs.failed }}"
    -
    -          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    -            echo "## ✅ E2E Full Extended Tests Passed" >> $GITHUB_STEP_SUMMARY
    -            echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
    -          else
    -            echo "## ❌ E2E Full Extended Tests Failed" >> $GITHUB_STEP_SUMMARY
    -            echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
    -            echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -            echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
    -            echo '```' >> $GITHUB_STEP_SUMMARY
    -          fi
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ github.sha }}
    +      suite: full-extended
    +      summary_title: E2E Full Extended Tests
    +      timeout_minutes: 20
    +      compose_file: tests/e2e/docker-compose-orchestrator.yml
    
  • .github/workflows/go-verify.yml+6 162 modified
    @@ -19,6 +19,7 @@ on:
           - '.gitignore'
           - 'skill/**'
           - 'plugin/**'
    +  workflow_dispatch:
     
     env:
       FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    @@ -27,165 +28,8 @@ permissions:
       contents: read
     
     jobs:
    -  # Check which files changed to conditionally run jobs
    -  changes:
    -    name: Detect Changes
    -    runs-on: ubuntu-latest
    -    if: github.event.pull_request.draft == false
    -    outputs:
    -      go: ${{ steps.filter.outputs.go }}
    -    steps:
    -      - uses: actions/checkout@v5
    -      - uses: dorny/paths-filter@v3
    -        id: filter
    -        with:
    -          filters: |
    -            go:
    -              - '**.go'
    -              - 'go.mod'
    -              - 'go.sum'
    -              - 'cmd/**'
    -              - 'internal/**'
    -
    -  build:
    -    name: Build & Vet
    -    needs: changes
    -    runs-on: ubuntu-latest
    -    steps:
    -      - name: Check for Go changes
    -        id: check
    -        run: |
    -          if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
    -            echo "Go files detected"
    -            echo "has_go_changes=true" >> $GITHUB_OUTPUT
    -          else
    -            echo "⏭️ No Go files changed - docs/workflow-only changes detected"
    -            echo "has_go_changes=false" >> $GITHUB_OUTPUT
    -          fi
    -
    -      - uses: actions/checkout@v5
    -
    -      - uses: actions/setup-go@v6
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        with:
    -          go-version: '1.26'
    -
    -      - name: Download deps
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: go mod download
    -
    -      - name: Format check
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: |
    -          unformatted=$(gofmt -l .)
    -          if [ -n "$unformatted" ]; then
    -            echo "Files not formatted:"
    -            echo "$unformatted"
    -            exit 1
    -          fi
    -
    -      - name: Vet
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: go vet ./...
    -
    -      - name: Build
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: go build -o pinchtab ./cmd/pinchtab
    -
    -      - name: Test with coverage
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: go test ./... -v -count=1 -coverprofile=coverage.out -covermode=atomic
    -
    -      - name: Coverage summary
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: |
    -          coverage=$(go tool cover -func=coverage.out | tail -1)
    -          echo "### Coverage" >> $GITHUB_STEP_SUMMARY
    -          echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
    -          echo "$coverage" >> $GITHUB_STEP_SUMMARY
    -          echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
    -
    -  lint:
    -    name: Lint
    -    needs: changes
    -    runs-on: ubuntu-latest
    -    steps:
    -      - name: Check for Go changes
    -        id: check
    -        run: |
    -          if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
    -            echo "has_go_changes=true" >> $GITHUB_OUTPUT
    -          else
    -            echo "⏭️ No Go files changed, skipping lint"
    -            echo "has_go_changes=false" >> $GITHUB_OUTPUT
    -          fi
    -
    -      - uses: actions/checkout@v5
    -
    -      - uses: actions/setup-go@v6
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        with:
    -          go-version: '1.26'
    -
    -      - uses: oven-sh/setup-bun@v2
    -        if: needs.changes.outputs.go == 'true'
    -        with:
    -          bun-version: latest
    -
    -      - name: Install tygo
    -        if: needs.changes.outputs.go == 'true'
    -        run: go install github.com/gzuidhof/tygo@latest
    -
    -      - name: Build dashboard
    -        if: needs.changes.outputs.go == 'true'
    -        run: |
    -          cd dashboard && bun install --frozen-lockfile && cd ..
    -          ./scripts/build-dashboard.sh
    -
    -      - name: golangci-lint
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        uses: golangci/golangci-lint-action@v7
    -        with:
    -          version: v2.9.0
    -
    -  security:
    -    name: Security Scan
    -    needs: changes
    -    runs-on: ubuntu-latest
    -    steps:
    -      - name: Check for Go changes
    -        id: check
    -        run: |
    -          if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
    -            echo "has_go_changes=true" >> $GITHUB_OUTPUT
    -          else
    -            echo "⏭️ No Go files changed, skipping security scan"
    -            echo "has_go_changes=false" >> $GITHUB_OUTPUT
    -          fi
    -
    -      - uses: actions/checkout@v5
    -
    -      - uses: actions/setup-go@v6
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        with:
    -          go-version: '1.26'
    -
    -      - name: Install gosec
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: go install github.com/securego/gosec/v2/cmd/gosec@latest
    -
    -      - name: Run gosec
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: gosec -exclude=G301,G302,G304,G306,G404,G107,G115,G703,G704,G705,G706 -fmt=json -out=gosec-results.json ./... || true
    -
    -      - name: Check critical findings
    -        if: steps.check.outputs.has_go_changes == 'true'
    -        run: |
    -          # Fail on G112 (Slowloris), G204 (command injection)
    -          ISSUES=$(cat gosec-results.json | jq '[.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")] | length')
    -          echo "Critical issues: $ISSUES"
    -          cat gosec-results.json | jq '.Stats'
    -          if [ "$ISSUES" -gt 0 ]; then
    -            cat gosec-results.json | jq '.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")'
    -            exit 1
    -          fi
    +  go:
    +    if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
    +    uses: ./.github/workflows/reusable-go-verify.yml
    +    with:
    +      ref: ${{ github.sha }}
    
  • .github/workflows/npm-verify.yml+11 139 modified
    @@ -5,9 +5,16 @@ on:
         paths:
           - 'npm/**'
           - '.github/workflows/npm-verify.yml'
    +      - '.github/workflows/reusable-npm-verify.yml'
       push:
         tags:
           - 'v*'
    +  workflow_dispatch:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
     
     env:
       FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    @@ -16,142 +23,7 @@ permissions:
       contents: read
     
     jobs:
    -  verify:
    -    name: Verify npm Package
    -    runs-on: ubuntu-latest
    -    steps:
    -      - uses: actions/checkout@v5
    -
    -      - uses: actions/setup-node@v5
    -        with:
    -          node-version: '22'
    -
    -      - name: Install dependencies
    -        working-directory: npm
    -        run: npm ci
    -
    -      - name: Lint (ESLint)
    -        working-directory: npm
    -        run: npm run lint
    -
    -      - name: Format Check (Prettier)
    -        working-directory: npm
    -        run: npm run format:check
    -
    -      - name: Build TypeScript
    -        working-directory: npm
    -        run: tsc
    -
    -      - name: Run tests
    -        working-directory: npm
    -        run: npm test
    -
    -      - name: Check for source .ts files in package
    -        working-directory: npm
    -        run: |
    -          # Create the tarball and check its actual contents
    -          npm pack > /dev/null 2>&1
    -          TARBALL=$(ls -t pinchtab-*.tgz | head -1)
    -          SOURCES=$(tar -tzf "$TARBALL" | grep -E "\.ts$" | grep -v "\.d\.ts" || true)
    -          if [ -n "$SOURCES" ]; then
    -            echo "❌ ERROR: Source .ts files in package:"
    -            echo "$SOURCES"
    -            exit 1
    -          fi
    -          echo "✅ No source .ts files in tarball"
    -          rm "$TARBALL"
    -
    -      - name: Check for test files in package
    -        working-directory: npm
    -        run: |
    -          TESTS=$(npm pack --dry-run 2>&1 | grep "dist/tests" || true)
    -          if [ -n "$TESTS" ]; then
    -            echo "❌ ERROR: Test files in package:"
    -            echo "$TESTS"
    -            exit 1
    -          fi
    -          echo "✅ No test files in package"
    -
    -      - name: Check for source maps in package
    -        working-directory: npm
    -        run: |
    -          MAPS=$(npm pack --dry-run 2>&1 | grep "\.map$" || true)
    -          if [ -n "$MAPS" ]; then
    -            echo "❌ ERROR: Source maps in package:"
    -            echo "$MAPS"
    -            exit 1
    -          fi
    -          echo "✅ No source maps in package"
    -
    -      - name: Verify required files exist
    -        working-directory: npm
    -        run: |
    -          REQUIRED=(
    -            "dist/src/index.js"
    -            "dist/src/index.d.ts"
    -            "scripts/postinstall.js"
    -            "bin/pinchtab"
    -            "LICENSE"
    -          )
    -
    -          PACK_OUTPUT=$(npm pack --dry-run 2>&1)
    -
    -          for file in "${REQUIRED[@]}"; do
    -            if echo "$PACK_OUTPUT" | grep -q "$file"; then
    -              echo "✅ $file"
    -            else
    -              echo "❌ Missing: $file"
    -              exit 1
    -            fi
    -          done
    -
    -      - name: Check postinstall.js syntax
    -        working-directory: npm
    -        run: |
    -          node -c scripts/postinstall.js
    -          echo "✅ postinstall.js valid JavaScript"
    -
    -      - name: Verify package metadata
    -        working-directory: npm
    -        run: |
    -          node -e "
    -            const pkg = require('./package.json');
    -            const checks = [
    -              { name: 'name', value: pkg.name === 'pinchtab' },
    -              { name: 'version', value: !!pkg.version },
    -              { name: 'files array', value: Array.isArray(pkg.files) && pkg.files.length > 0 },
    -              { name: 'postinstall script', value: !!pkg.scripts.postinstall },
    -              { name: 'bin.pinchtab', value: !!pkg.bin.pinchtab }
    -            ];
    -
    -            let failed = false;
    -            checks.forEach(check => {
    -              if (check.value) {
    -                console.log('✅', check.name);
    -              } else {
    -                console.log('❌', check.name, 'missing or invalid');
    -                failed = true;
    -              }
    -            });
    -
    -            if (failed) process.exit(1);
    -          "
    -
    -      - name: Check package size
    -        working-directory: npm
    -        run: |
    -          SIZE=$(npm pack --dry-run 2>&1 | tail -1 | awk '{print $NF}' | tr -d 'B')
    -          echo "📦 Package size: ${SIZE}B"
    -          if [ "$SIZE" -gt 100000 ]; then
    -            echo "⚠️  Warning: Package is large (>100KB)"
    -          fi
    -
    -      - name: Security audit
    -        working-directory: npm
    -        run: npm audit --audit-level=moderate || true
    -
    -      - name: List package contents
    -        working-directory: npm
    -        run: |
    -          echo "📦 Package will contain:"
    -          npm pack --dry-run 2>&1 | grep "notice" | awk '{print "  " $3 " (" $2 ")"}'
    +  npm:
    +    uses: ./.github/workflows/reusable-npm-verify.yml
    +    with:
    +      ref: ${{ inputs.ref || github.sha }}
    
  • .github/workflows/prepare-release.yml+217 0 added
    @@ -0,0 +1,217 @@
    +name: Prepare Release
    +
    +on:
    +  workflow_dispatch:
    +    inputs:
    +      version:
    +        description: 'Release version (for example 0.8.0, without v prefix)'
    +        required: true
    +      ref:
    +        description: 'Git ref to release. Defaults to main.'
    +        required: false
    +        default: 'main'
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +  GORELEASER_VERSION: v2.14.3
    +
    +permissions:
    +  contents: write
    +
    +concurrency:
    +  group: prepare-release-${{ inputs.version }}
    +  cancel-in-progress: false
    +
    +jobs:
    +  validate:
    +    name: Validate Release Input
    +    runs-on: ubuntu-latest
    +    outputs:
    +      version: ${{ steps.meta.outputs.version }}
    +      tag: ${{ steps.meta.outputs.tag }}
    +      sha: ${{ steps.meta.outputs.sha }}
    +      ref: ${{ steps.meta.outputs.ref }}
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          fetch-depth: 0
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Validate version and tag target
    +        id: meta
    +        run: |
    +          VERSION="${{ inputs.version }}"
    +          VERSION="${VERSION#v}"
    +          if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    +            echo "version must look like 0.8.0" >&2
    +            exit 1
    +          fi
    +
    +          TAG="v$VERSION"
    +          PKG_VERSION="$(jq -r .version npm/package.json)"
    +          if [ "$PKG_VERSION" != "$VERSION" ]; then
    +            echo "npm/package.json version is $PKG_VERSION, expected $VERSION" >&2
    +            exit 1
    +          fi
    +
    +          if git rev-parse "$TAG" >/dev/null 2>&1; then
    +            echo "tag $TAG already exists locally" >&2
    +            exit 1
    +          fi
    +
    +          if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
    +            echo "tag $TAG already exists on origin" >&2
    +            exit 1
    +          fi
    +
    +          SHA="$(git rev-parse HEAD)"
    +          {
    +            echo "version=$VERSION"
    +            echo "tag=$TAG"
    +            echo "sha=$SHA"
    +            echo "ref=${{ inputs.ref }}"
    +          } >> "$GITHUB_OUTPUT"
    +
    +      - name: Summarize candidate
    +        run: |
    +          echo "## Release Candidate" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Version: ${{ steps.meta.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Tag: ${{ steps.meta.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Ref: ${{ steps.meta.outputs.ref }}" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Commit: \`${{ steps.meta.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
    +
    +  go-checks:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-go-verify.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +
    +  dashboard-checks:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-dashboard.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +
    +  docs-checks:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-docs-verify.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +
    +  npm-checks:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-npm-verify.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +
    +  e2e-release:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-e2e-suite.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +      suite: release
    +      summary_title: Release E2E
    +      timeout_minutes: 45
    +
    +  docker-smoke:
    +    needs: validate
    +    uses: ./.github/workflows/reusable-docker-smoke.yml
    +    with:
    +      ref: ${{ needs.validate.outputs.sha }}
    +
    +  publish-dry-run:
    +    name: Release Dry Runs
    +    needs: validate
    +    runs-on: ubuntu-latest
    +    timeout-minutes: 20
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          fetch-depth: 0
    +          ref: ${{ needs.validate.outputs.sha }}
    +
    +      - uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - uses: actions/setup-node@v5
    +        with:
    +          node-version: '22'
    +
    +      - uses: oven-sh/setup-bun@v2
    +        with:
    +          bun-version: latest
    +
    +      - name: Install tygo
    +        run: go install github.com/gzuidhof/tygo@latest
    +
    +      - name: Install dashboard dependencies
    +        working-directory: dashboard
    +        run: bun install --frozen-lockfile
    +
    +      - name: Run GoReleaser snapshot
    +        run: |
    +          curl -sfL https://goreleaser.com/static/run | VERSION="$GORELEASER_VERSION" bash -s -- release --clean --snapshot
    +        env:
    +          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    +          HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
    +
    +      - name: Dry-run npm publish
    +        working-directory: npm
    +        run: |
    +          npm ci --ignore-scripts
    +          npm run build
    +          npm publish --dry-run
    +
    +  approval:
    +    name: Manual Approval
    +    needs:
    +      - validate
    +      - go-checks
    +      - dashboard-checks
    +      - docs-checks
    +      - npm-checks
    +      - e2e-release
    +      - docker-smoke
    +      - publish-dry-run
    +    runs-on: ubuntu-latest
    +    environment:
    +      name: release-approval
    +    steps:
    +      - name: Await approval
    +        run: |
    +          echo "Approved release candidate ${{ needs.validate.outputs.tag }} at ${{ needs.validate.outputs.sha }}"
    +
    +  create-tag:
    +    name: Create Release Tag
    +    needs:
    +      - validate
    +      - approval
    +    runs-on: ubuntu-latest
    +    permissions:
    +      contents: write
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          fetch-depth: 0
    +          ref: ${{ needs.validate.outputs.sha }}
    +
    +      - name: Create and push tag
    +        run: |
    +          TAG="${{ needs.validate.outputs.tag }}"
    +
    +          if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
    +            echo "tag $TAG already exists on origin" >&2
    +            exit 1
    +          fi
    +
    +          git config user.name "github-actions[bot]"
    +          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
    +          git tag -a "$TAG" -m "Release $TAG"
    +          git push origin "refs/tags/$TAG"
    +
    +      - name: Summarize tag creation
    +        run: |
    +          echo "## Release Tag Created" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Tag: ${{ needs.validate.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
    +          echo "- Commit: \`${{ needs.validate.outputs.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
    
  • .github/workflows/release.yml+22 99 modified
    @@ -1,41 +1,8 @@
     name: Release
     
    -# Automatic release pipeline triggered on tag push (v0.7.0, etc.)
    -#
    -# Pipeline:
    -#   1. Checkout tag
    -#   2. Build Go binary + create GitHub release (goreleaser)
    -#   3. Publish npm package (with postinstall binary download support)
    -#   4. Build & push Docker images
    -# See .github/RELEASING.md for the operational checklist.
    -#
    -# Secrets required:
    -#   - NPM_TOKEN: npm authentication (https://docs.npmjs.com/creating-and-viewing-access-tokens)
    -#   - DOCKERHUB_USER / DOCKERHUB_TOKEN: Docker Hub credentials
    -#   - GITHUB_TOKEN: Automatic (GitHub Actions)
    -#   - HOMEBREW_TAP_GITHUB_TOKEN: PAT with write access to pinchtab/homebrew-tap
    -#
    -# To create a release:
    -#   git tag v0.7.0
    -#   git push origin v0.7.0
    -
     on:
       push:
         tags: ['v*']
    -  workflow_dispatch:
    -    inputs:
    -      tag:
    -        description: 'Tag to release (e.g. v0.2.0). Leave empty for dry-run from ref.'
    -        required: false
    -      ref:
    -        description: 'Git ref for dry-run testing (branch, tag, or SHA). Defaults to main.'
    -        required: false
    -        default: 'main'
    -      dry_run:
    -        description: 'Build release artifacts without publishing them.'
    -        required: false
    -        type: boolean
    -        default: false
     
     env:
       FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    @@ -44,25 +11,21 @@ env:
     permissions:
       contents: write
     
    +concurrency:
    +  group: release-${{ github.ref_name }}
    +  cancel-in-progress: false
    +
     jobs:
       release:
         name: Release Binaries
         runs-on: ubuntu-latest
         permissions:
           contents: write
         steps:
    -      - name: Validate workflow inputs
    -        if: ${{ github.event_name == 'workflow_dispatch' }}
    -        run: |
    -          if [ "${{ inputs.dry_run }}" != "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
    -            echo "tag is required unless dry_run=true" >&2
    -            exit 1
    -          fi
    -
           - uses: actions/checkout@v5
             with:
               fetch-depth: 0
    -          ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
    +          ref: ${{ github.ref }}
     
           - uses: actions/setup-go@v6
             with:
    @@ -78,13 +41,7 @@ jobs:
     
           - name: Run GoReleaser
             run: |
    -          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
    -            ARGS="release --clean --snapshot"
    -          else
    -            ARGS="release --clean"
    -          fi
    -
    -          curl -sfL https://goreleaser.com/static/run | VERSION="$GORELEASER_VERSION" bash -s -- $ARGS
    +          curl -sfL https://goreleaser.com/static/run | VERSION="$GORELEASER_VERSION" bash -s -- release --clean
             env:
               GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
               HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
    @@ -98,7 +55,7 @@ jobs:
         steps:
           - uses: actions/checkout@v5
             with:
    -          ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
    +          ref: ${{ github.ref }}
     
           - uses: actions/setup-node@v5
             with:
    @@ -108,16 +65,12 @@ jobs:
           - name: Extract version
             id: version
             run: |
    -          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
    -            VERSION=$(jq -r .version npm/package.json)
    -          else
    -            TAG="${{ github.event.inputs.tag || github.ref_name }}"
    -            VERSION=${TAG#v}  # Remove 'v' prefix
    -          fi
    -          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
    +          TAG="${{ github.ref_name }}"
    +          VERSION="${TAG#v}"
    +          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
    +          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
     
           - name: Update npm package version
    -        if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
             working-directory: npm
             run: |
               CURRENT=$(jq -r .version package.json)
    @@ -136,15 +89,7 @@ jobs:
             working-directory: npm
             run: npm run build
     
    -      - name: Dry-run npm publish
    -        if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
    -        working-directory: npm
    -        run: npm publish --dry-run
    -        env:
    -          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    -
           - name: Publish to npm
    -        if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
             working-directory: npm
             run: npm publish
             env:
    @@ -153,55 +98,34 @@ jobs:
       docker:
         name: Docker Image
         runs-on: ubuntu-latest
    -    needs: release
    +    needs:
    +      - release
    +      - npm
         permissions:
           contents: read
           packages: write
         steps:
           - uses: actions/checkout@v5
             with:
    -          ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
    +          ref: ${{ github.ref }}
     
           - name: Extract Docker metadata
             id: meta
             run: |
    -          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
    -            if [ -n "${{ github.event.inputs.tag }}" ]; then
    -              TAG="${{ github.event.inputs.tag }}"
    -              VERSION="${TAG#v}-dryrun-${GITHUB_SHA::7}"
    -            else
    -              TAG="dry-run-${GITHUB_SHA::7}"
    -              VERSION="$(jq -r .version npm/package.json)-dryrun-${GITHUB_SHA::7}"
    -            fi
    -            PUSH="false"
    -          else
    -            TAG="${{ github.event.inputs.tag || github.ref_name }}"
    -            VERSION="${TAG#v}"
    -            PUSH="true"
    -          fi
    -
    -          {
    -            echo "tag=$TAG"
    -            echo "version=$VERSION"
    -            echo "push=$PUSH"
    -          } >> "$GITHUB_OUTPUT"
    -
    -      - name: Docker dry-run note
    -        if: ${{ steps.meta.outputs.push != 'true' }}
    -        run: |
    -          echo "Running a multi-arch Docker build without pushing to GHCR or Docker Hub." >> "$GITHUB_STEP_SUMMARY"
    +          TAG="${{ github.ref_name }}"
    +          VERSION="${TAG#v}"
    +          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
    +          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
     
           - uses: docker/setup-qemu-action@v3
           - uses: docker/setup-buildx-action@v3
     
           - uses: docker/login-action@v3
    -        if: ${{ steps.meta.outputs.push == 'true' }}
             with:
               username: ${{ secrets.DOCKERHUB_USER }}
               password: ${{ secrets.DOCKERHUB_TOKEN }}
     
           - uses: docker/login-action@v3
    -        if: ${{ steps.meta.outputs.push == 'true' }}
             with:
               registry: ghcr.io
               username: ${{ github.actor }}
    @@ -212,7 +136,7 @@ jobs:
               context: .
               file: ./Dockerfile
               platforms: linux/amd64,linux/arm64
    -          push: ${{ steps.meta.outputs.push == 'true' }}
    +          push: true
               provenance: false
               labels: |
                 org.opencontainers.image.source=https://github.com/pinchtab/pinchtab
    @@ -228,11 +152,10 @@ jobs:
       skill:
         name: Publish Skill
         needs:
    +      - release
           - npm
    -      - docker
    -    if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
         uses: ./.github/workflows/publish-skill.yml
         with:
    -      version: ${{ github.event.inputs.tag || github.ref_name }}
    +      version: ${{ github.ref_name }}
         secrets:
           CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
    
  • .github/workflows/reusable-dashboard.yml+101 0 added
    @@ -0,0 +1,101 @@
    +name: Reusable Dashboard Checks
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +defaults:
    +  run:
    +    working-directory: dashboard
    +
    +jobs:
    +  check:
    +    name: Lint, Type Check & Test
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Setup Go
    +        uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - name: Setup Bun
    +        uses: oven-sh/setup-bun@v2
    +        with:
    +          bun-version: latest
    +
    +      - name: Install tygo
    +        run: go install github.com/gzuidhof/tygo@latest
    +
    +      - name: Install dependencies
    +        run: bun install --frozen-lockfile
    +
    +      - name: Generate dashboard types
    +        run: |
    +          $HOME/go/bin/tygo generate
    +          npx prettier --write src/generated/types.ts
    +          git diff --exit-code -- src/generated/types.ts
    +
    +      - name: TypeScript check
    +        run: bun run typecheck
    +
    +      - name: ESLint
    +        run: bun run lint
    +
    +      - name: Prettier check
    +        run: bun run format:check
    +
    +      - name: Run tests
    +        run: bun run test:run
    +
    +  build:
    +    name: Build
    +    runs-on: ubuntu-latest
    +    needs: check
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Setup Go
    +        uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - name: Setup Bun
    +        uses: oven-sh/setup-bun@v2
    +        with:
    +          bun-version: latest
    +
    +      - name: Install tygo
    +        run: go install github.com/gzuidhof/tygo@latest
    +
    +      - name: Install dependencies
    +        run: bun install --frozen-lockfile
    +
    +      - name: Generate dashboard types
    +        run: |
    +          $HOME/go/bin/tygo generate
    +          npx prettier --write src/generated/types.ts
    +
    +      - name: Build
    +        run: bun run build
    +
    +      - name: Upload build artifacts
    +        uses: actions/upload-artifact@v4
    +        with:
    +          name: dashboard-dist
    +          path: dashboard/dist/
    +          retention-days: 7
    
  • .github/workflows/reusable-docker-smoke.yml+32 0 added
    @@ -0,0 +1,32 @@
    +name: Reusable Docker Smoke Test
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  smoke:
    +    name: Docker Smoke Test
    +    runs-on: ubuntu-latest
    +    timeout-minutes: 20
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Build local Docker image
    +        run: docker build -t pinchtab-release-smoke:${{ github.run_id }} .
    +        env:
    +          DOCKER_BUILDKIT: 1
    +
    +      - name: Verify bootstrap path in container
    +        run: bash scripts/docker-smoke.sh pinchtab-release-smoke:${{ github.run_id }}
    
  • .github/workflows/reusable-docs-verify.yml+29 0 added
    @@ -0,0 +1,29 @@
    +name: Reusable Documentation Verification
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  docs-validate:
    +    name: Validate Documentation
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Check docs.json references
    +        run: |
    +          echo "🔍 Validating docs.json..."
    +          ./scripts/check-docs-json.sh
    +        shell: bash
    
  • .github/workflows/reusable-e2e-suite.yml+124 0 added
    @@ -0,0 +1,124 @@
    +name: Reusable E2E Suite
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +      suite:
    +        required: true
    +        type: string
    +      summary_title:
    +        required: true
    +        type: string
    +      timeout_minutes:
    +        required: true
    +        type: number
    +      compose_file:
    +        required: false
    +        type: string
    +        default: ''
    +      dump_instance_inventory:
    +        required: false
    +        type: boolean
    +        default: false
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  run:
    +    name: ${{ inputs.summary_title }}
    +    runs-on: ubuntu-latest
    +    timeout-minutes: ${{ inputs.timeout_minutes }}
    +    steps:
    +      - name: Checkout
    +        uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - name: Set up Go
    +        uses: actions/setup-go@v6
    +        with:
    +          go-version-file: go.mod
    +          cache: true
    +
    +      - name: Set up Docker Buildx
    +        uses: docker/setup-buildx-action@v3
    +
    +      - name: Run E2E suite
    +        id: e2e
    +        run: |
    +          set +e
    +          mkdir -p tests/e2e/results
    +
    +          ./dev e2e "${{ inputs.suite }}" 2>&1 | tee e2e-output.log
    +          EXIT_CODE=${PIPESTATUS[0]}
    +
    +          PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    +          FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | awk '{s+=$1} END {print s+0}')
    +          FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
    +
    +          echo "passed=$PASSED" >> "$GITHUB_OUTPUT"
    +          echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
    +          {
    +            echo 'failures<<EOF'
    +            echo "$FAILURES"
    +            echo 'EOF'
    +          } >> "$GITHUB_OUTPUT"
    +
    +          exit $EXIT_CODE
    +        env:
    +          DOCKER_BUILDKIT: 1
    +          COMPOSE_DOCKER_CLI_BUILD: 1
    +
    +      - name: Dump pinchtab logs on failure
    +        if: failure() && inputs.compose_file != ''
    +        run: |
    +          echo "==== pinchtab logs ===="
    +          docker compose -f "${{ inputs.compose_file }}" logs pinchtab || true
    +
    +      - name: Dump instance inventory on failure
    +        if: failure() && inputs.dump_instance_inventory
    +        run: |
    +          echo ""
    +          echo "==== filtered Chrome/extension lines ===="
    +          docker compose -f "${{ inputs.compose_file }}" logs pinchtab 2>/dev/null | grep -Ei 'chrome|extension|devtools|load-extension|disable-extensions|headless|warning|error' || true
    +          echo ""
    +          echo "==== instance inventory ===="
    +          INSTANCES_JSON=$(curl -sf http://localhost:9999/instances || true)
    +          if [ -n "$INSTANCES_JSON" ]; then
    +            echo "$INSTANCES_JSON"
    +            if command -v jq >/dev/null 2>&1; then
    +              echo "$INSTANCES_JSON" | jq -r '.[].id' | while read -r inst_id; do
    +                [ -z "$inst_id" ] && continue
    +                echo ""
    +                echo "==== logs for $inst_id ===="
    +                curl -sf "http://localhost:9999/instances/$inst_id/logs" || true
    +              done
    +            fi
    +          else
    +            echo "unable to fetch /instances from localhost:9999"
    +          fi
    +
    +      - name: Post summary
    +        if: always()
    +        run: |
    +          PASSED="${{ steps.e2e.outputs.passed }}"
    +          FAILED="${{ steps.e2e.outputs.failed }}"
    +
    +          if [ "${{ steps.e2e.outcome }}" = "success" ]; then
    +            echo "## ✅ ${{ inputs.summary_title }} Passed" >> "$GITHUB_STEP_SUMMARY"
    +            echo "**$PASSED** tests passed" >> "$GITHUB_STEP_SUMMARY"
    +          else
    +            echo "## ❌ ${{ inputs.summary_title }} Failed" >> "$GITHUB_STEP_SUMMARY"
    +            echo "**Passed:** $PASSED" >> "$GITHUB_STEP_SUMMARY"
    +            echo "**Failed:** $FAILED" >> "$GITHUB_STEP_SUMMARY"
    +            echo '```' >> "$GITHUB_STEP_SUMMARY"
    +            echo "${{ steps.e2e.outputs.failures }}" >> "$GITHUB_STEP_SUMMARY"
    +            echo '```' >> "$GITHUB_STEP_SUMMARY"
    +          fi
    
  • .github/workflows/reusable-go-verify.yml+113 0 added
    @@ -0,0 +1,113 @@
    +name: Reusable Go Verification
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  build:
    +    name: Build & Vet
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - name: Download deps
    +        run: go mod download
    +
    +      - name: Format check
    +        run: |
    +          unformatted=$(gofmt -l .)
    +          if [ -n "$unformatted" ]; then
    +            echo "Files not formatted:"
    +            echo "$unformatted"
    +            exit 1
    +          fi
    +
    +      - name: Vet
    +        run: go vet ./...
    +
    +      - name: Build
    +        run: go build -o pinchtab ./cmd/pinchtab
    +
    +      - name: Test with coverage
    +        run: go test ./... -v -count=1 -coverprofile=coverage.out -covermode=atomic
    +
    +      - name: Coverage summary
    +        run: |
    +          coverage=$(go tool cover -func=coverage.out | tail -1)
    +          echo "### Coverage" >> "$GITHUB_STEP_SUMMARY"
    +          echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY"
    +          echo "$coverage" >> "$GITHUB_STEP_SUMMARY"
    +          echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY"
    +
    +  lint:
    +    name: Lint
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - uses: oven-sh/setup-bun@v2
    +        with:
    +          bun-version: latest
    +
    +      - name: Install tygo
    +        run: go install github.com/gzuidhof/tygo@latest
    +
    +      - name: Build dashboard
    +        run: |
    +          cd dashboard && bun install --frozen-lockfile && cd ..
    +          ./scripts/build-dashboard.sh
    +
    +      - name: golangci-lint
    +        uses: golangci/golangci-lint-action@v7
    +        with:
    +          version: v2.9.0
    +
    +  security:
    +    name: Security Scan
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - uses: actions/setup-go@v6
    +        with:
    +          go-version: '1.26'
    +
    +      - name: Install gosec
    +        run: go install github.com/securego/gosec/v2/cmd/gosec@latest
    +
    +      - name: Run gosec
    +        run: gosec -exclude=G301,G302,G304,G306,G404,G107,G115,G703,G704,G705,G706 -fmt=json -out=gosec-results.json ./... || true
    +
    +      - name: Check critical findings
    +        run: |
    +          ISSUES=$(cat gosec-results.json | jq '[.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")] | length')
    +          echo "Critical issues: $ISSUES"
    +          cat gosec-results.json | jq '.Stats'
    +          if [ "$ISSUES" -gt 0 ]; then
    +            cat gosec-results.json | jq '.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")'
    +            exit 1
    +          fi
    
  • .github/workflows/reusable-npm-verify.yml+36 0 added
    @@ -0,0 +1,36 @@
    +name: Reusable npm Package Verification
    +
    +on:
    +  workflow_call:
    +    inputs:
    +      ref:
    +        required: true
    +        type: string
    +
    +env:
    +  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  verify:
    +    name: Verify npm Package
    +    runs-on: ubuntu-latest
    +    steps:
    +      - uses: actions/checkout@v5
    +        with:
    +          ref: ${{ inputs.ref }}
    +
    +      - uses: actions/setup-node@v5
    +        with:
    +          node-version: '22'
    +
    +      - name: Verify npm package
    +        run: bash scripts/check-npm.sh
    +
    +      - name: List package contents
    +        working-directory: npm
    +        run: |
    +          echo "📦 Package will contain:"
    +          npm pack --dry-run 2>&1 | grep "notice" | awk '{print "  " $3 " (" $2 ")"}'
    
  • README.md+1 0 modified
    @@ -355,6 +355,7 @@ The full setup and workflow guide lives at [docs/guides/contributing.md](docs/gu
     git clone https://github.com/pinchtab/pinchtab.git
     cd pinchtab
     ./dev doctor                # Verifies environment, offers hooks/deps setup
    +./dev --help                # Shows the developer toolkit commands
     go build ./cmd/pinchtab     # Build pinchtab binary
     ```
     
    
  • RELEASE.md+35 49 modified
    @@ -8,6 +8,7 @@ Pinchtab uses an automated CI/CD pipeline triggered by Git tags. When you push a
     2. **Creates GitHub release** — with checksums.txt for integrity verification
     3. **Publishes to npm** — TypeScript SDK with auto-download postinstall script
     4. **Builds Docker images** — linux/amd64, linux/arm64
    +5. **Publishes the ClawHub skill** — after npm succeeds
     
     ## Prerequisites
     
    @@ -32,7 +33,7 @@ git checkout main && git pull origin main
     # (all features should be on main before tagging)
     
     # 3. Verify version consistency
    -cat package.json | jq .version     # npm package
    +cat npm/package.json | jq .version # npm package
     cat go.mod | grep "module"         # Go module
     git describe --tags                # latest tag
     ```
    @@ -65,54 +66,34 @@ grep -A 2 "^release:" .goreleaser.yml
     - ✅ `checksum:` generates `checksums.txt` (used by npm postinstall verification)
     - ✅ `release:` points to GitHub (uploads everything automatically)
     
    -**Ready to release.** Just tag and push.
    +**Ready to release.** The recommended path is now the manual `Prepare Release` workflow, which runs the full verification suite, pauses for approval, and only then creates the tag.
     
     ## Releasing
     
    -### For patch/minor versions (recommended)
    -
    -```bash
    -# 1. Bump version in all places
    -npm version patch   # or minor, major
    -git push origin main
    -
    -# 2. Create tag
    -git tag v0.7.1
    -git push origin v0.7.1
    -```
    -
    -### For manual releases
    -
    -```bash
    -# 1. Tag directly
    -git tag -a v0.7.1 -m "Release v0.7.1"
    -git push origin v0.7.1
    -
    -# 2. Or via GitHub UI: Releases → Create from tag
    -#    (workflow will auto-create if not present)
    -```
    -
    -### Using workflow_dispatch (manual trigger)
    -
    -If you need to re-release an existing tag:
    -
    -1. Go to **Actions → Release**
    -2. **Run workflow**
    -3. Enter tag (e.g. `v0.7.1`)
    -
    -For a no-side-effects dry run from `main` or any other ref:
    -
    -1. Go to **Actions → Release**
    -2. **Run workflow**
    -3. Leave `tag` empty
    -4. Set `ref` to the branch, tag, or commit you want to test
    -5. Set `dry_run` to `true`
    -
    -Dry-run behavior:
    -- GoReleaser runs in snapshot mode, so artifacts are built but not published
    -- npm runs `npm publish --dry-run`
    -- Docker runs a multi-arch `buildx` build with `push=false`
    -- ClawHub skill publishing is not part of this workflow and is therefore skipped
    +## Manual Release Flow
    +
    +1. Bump release-facing versions on the branch first.
    +   The hard gate today is `npm/package.json`, and `Prepare Release` will fail if the input version does not match it.
    +2. Open **Actions → Prepare Release**.
    +3. Run it with:
    +   - `version`: the release version, for example `0.8.0`
    +   - `ref`: the branch, tag, or SHA you want to release
    +4. The workflow runs:
    +   - Go checks
    +   - dashboard checks
    +   - npm package verification
    +   - docs verification
    +   - full release E2E
    +   - Docker bootstrap smoke test
    +   - GoReleaser snapshot
    +   - `npm publish --dry-run`
    +5. After those pass, GitHub pauses at the `release-approval` environment gate.
    +6. Approve the run. The workflow creates and pushes `v0.8.0` from the exact tested commit SHA.
    +7. That tag triggers the `Release` workflow automatically.
    +
    +### Required GitHub setup
    +
    +Configure a protected environment named `release-approval` with the required reviewers for the manual gate to be effective.
     
     ## Pipeline details
     
    @@ -162,13 +143,18 @@ Depends on: `release` job (waits for goreleaser to finish)
     
     ### 3. Docker
     
    +Depends on: `release` and `npm`
    +
     GitHub Actions builds the release image directly from the tagged source with `docker buildx`.
    -The workflow pushes the same multi-arch build to both GHCR and Docker Hub, so Docker no longer depends on GoReleaser's temporary build context.
    +The workflow pushes the same multi-arch build to both GHCR and Docker Hub, but only after npm has already published successfully.
     
     ### 4. ClawHub skill
     
    -The main `Release` workflow publishes the Pinchtab skill to ClawHub only after both npm and Docker complete successfully.
    -The standalone `Publish Skill` workflow is manual-only and is intended for retries or one-off recovery publishes.
    +Depends on: `release` and `npm`
    +
    +The main `Release` workflow publishes the Pinchtab skill to ClawHub automatically after npm succeeds.
    +It does not consume npm artifacts or GitHub release binaries directly; the ordering is a policy choice so npm stays the first irreversible publish step.
    +The standalone `Publish Skill` workflow remains available for retries or one-off recovery publishes.
     
     ## Troubleshooting
     
    
  • scripts/check-npm.sh+80 0 added
    @@ -0,0 +1,80 @@
    +#!/usr/bin/env bash
    +set -euo pipefail
    +
    +cd "$(dirname "$0")/../npm"
    +
    +echo "Verifying npm package..."
    +
    +npm ci
    +npm run lint
    +npm run format:check
    +npx tsc
    +npm test
    +
    +npm pack >/dev/null 2>&1
    +TARBALL="$(ls -t pinchtab-*.tgz | head -1)"
    +trap 'rm -f "$TARBALL"' EXIT
    +
    +SOURCES="$(tar -tzf "$TARBALL" | grep -E '\.ts$' | grep -v '\.d\.ts' || true)"
    +if [ -n "$SOURCES" ]; then
    +  echo "ERROR: Source .ts files in package:"
    +  echo "$SOURCES"
    +  exit 1
    +fi
    +
    +TESTS="$(npm pack --dry-run 2>&1 | grep 'dist/tests' || true)"
    +if [ -n "$TESTS" ]; then
    +  echo "ERROR: Test files in package:"
    +  echo "$TESTS"
    +  exit 1
    +fi
    +
    +MAPS="$(npm pack --dry-run 2>&1 | grep '\.map$' || true)"
    +if [ -n "$MAPS" ]; then
    +  echo "ERROR: Source maps in package:"
    +  echo "$MAPS"
    +  exit 1
    +fi
    +
    +REQUIRED=(
    +  "dist/src/index.js"
    +  "dist/src/index.d.ts"
    +  "scripts/postinstall.js"
    +  "bin/pinchtab"
    +  "LICENSE"
    +)
    +
    +PACK_OUTPUT="$(npm pack --dry-run 2>&1)"
    +for file in "${REQUIRED[@]}"; do
    +  if ! echo "$PACK_OUTPUT" | grep -q "$file"; then
    +    echo "ERROR: Missing required package file: $file"
    +    exit 1
    +  fi
    +done
    +
    +node -c scripts/postinstall.js
    +
    +node -e "
    +  const pkg = require('./package.json');
    +  const checks = [
    +    { name: 'name', value: pkg.name === 'pinchtab' },
    +    { name: 'version', value: !!pkg.version },
    +    { name: 'files array', value: Array.isArray(pkg.files) && pkg.files.length > 0 },
    +    { name: 'postinstall script', value: !!pkg.scripts.postinstall },
    +    { name: 'bin.pinchtab', value: !!pkg.bin.pinchtab }
    +  ];
    +
    +  let failed = false;
    +  for (const check of checks) {
    +    if (!check.value) {
    +      console.error('ERROR:', check.name, 'missing or invalid');
    +      failed = true;
    +    }
    +  }
    +
    +  if (failed) process.exit(1);
    +"
    +
    +npm audit --audit-level=moderate || true
    +
    +echo "npm package verification passed."
    
  • scripts/docker-smoke.sh+66 0 added
    @@ -0,0 +1,66 @@
    +#!/usr/bin/env bash
    +set -euo pipefail
    +
    +IMAGE="${1:-pinchtab-local:test}"
    +
    +NAME="pinchtab-smoke-${RANDOM}${RANDOM}"
    +FAILED=0
    +
    +cleanup() {
    +  if docker ps -a --format '{{.Names}}' | grep -Fxq "$NAME"; then
    +    if [ "$FAILED" -ne 0 ]; then
    +      echo ""
    +      echo "Container logs:"
    +      docker logs "$NAME" || true
    +    fi
    +    docker rm -f "$NAME" >/dev/null 2>&1 || true
    +  fi
    +}
    +trap cleanup EXIT
    +
    +docker run -d --name "$NAME" -p 127.0.0.1::9867 "$IMAGE" >/dev/null
    +
    +HOST_PORT="$(docker port "$NAME" 9867/tcp | head -1 | awk -F: '{print $NF}')"
    +if [ -z "$HOST_PORT" ]; then
    +  FAILED=1
    +  echo "failed to determine published host port"
    +  exit 1
    +fi
    +
    +AUTH_HEADER=()
    +TOKEN="$(docker exec "$NAME" pinchtab config get server.token | tr -d '\r')"
    +if [ -n "$TOKEN" ]; then
    +  AUTH_HEADER=(-H "Authorization: Bearer ${TOKEN}")
    +fi
    +
    +echo "Waiting for PinchTab to become healthy on port $HOST_PORT..."
    +for _ in $(seq 1 60); do
    +  if curl -fsS "${AUTH_HEADER[@]}" "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then
    +    break
    +  fi
    +  sleep 1
    +done
    +
    +if ! curl -fsS "${AUTH_HEADER[@]}" "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then
    +  FAILED=1
    +  echo "health check did not pass"
    +  exit 1
    +fi
    +
    +bind_addr="$(docker exec "$NAME" pinchtab config get server.bind | tr -d '\r')"
    +if [ "$bind_addr" != "0.0.0.0" ]; then
    +  FAILED=1
    +  echo "unexpected server.bind: $bind_addr"
    +  exit 1
    +fi
    +
    +extra_flags="$(docker exec "$NAME" pinchtab config get browser.extraFlags | tr -d '\r')"
    +if [ "$extra_flags" != "--no-sandbox --disable-gpu" ]; then
    +  FAILED=1
    +  echo "unexpected browser.extraFlags: $extra_flags"
    +  exit 1
    +fi
    +
    +docker exec "$NAME" test -f /data/.config/pinchtab/config.json
    +
    +echo "Docker smoke test passed."
    
  • scripts/README.md+6 0 modified
    @@ -23,6 +23,7 @@ Development and CI scripts for PinchTab.
     | `binary.sh` | Release-style stripped binary build into `dist/` for the current platform, or the full matrix with `all` |
     | `build-dashboard.sh` | Generate TS types (tygo) + build React dashboard + copy to Go embed |
     | `dev.sh` | Full build (dashboard + Go) and run |
    +| `docker-smoke.sh` | Smoke-test a Docker image bootstrap path (defaults to `pinchtab-local:test`) |
     | `run.sh` | Run the existing `./pinchtab` binary |
     
     ## Setup
    @@ -38,3 +39,8 @@ Development and CI scripts for PinchTab.
     |--------|---------|
     | `simulate-memory-load.sh` | Memory load testing |
     | `simulate-ratelimit-leak.sh` | Rate limit leak testing |
    +
    +## `./dev` shortcuts
    +
    +- `./dev e2e docker` builds `pinchtab-local:test` from the current checkout with `buildx --load` for the local Docker platform, then runs the Docker smoke test.
    +- `./dev e2e docker <image>` skips the build and smoke-tests the specified image tag.
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.