CI integration

Shipmoor Team
June 30, 2026
7 min read

In CI, Shipmoor Code Review posts advisory PR comments that never gate the build. The deterministic gate stays the shipmoor scan exit code. The review is layered on top as advice. Because a review drives a bring-your-own-agent that can hold a provider key and read source, the reference design uses a split workflow so an untrusted fork PR can never combine those two.

The reference example ships in the Shipmoor CLI repository at .github/workflows/shipmoor-review.yml (two workflows kept in one file for shipping; copy them into two files to adopt), with the rationale and a per-agent decision table in shipmoor-review/ci/README.md.

The one non-negotiable: advisory never gates

The gate is the exit code of the shipmoor scan --fail-on step: exit 1 fails the job, exit 0 passes it. The advisory reviewer (shipmoor review --json plus the poster) is a step that:

  • runs if: always() (so you still get advice on a change that just failed the gate, the most useful moment),
  • is || true-guarded (so a reviewer crash, a bring-your-own-agent timeout, or a poster error can never change the job’s conclusion), and
  • writes to a separate artifact (the review JSON, never the gate’s SARIF).

This is reinforced by the structural guarantee: code_review findings are gate-excluded, so even if you read them, the build’s verdict stays the scan exit code.

The split-workflow trust model

A shipmoor review agent executes tools (file_read, code_search, file_read_diff, file_find) over the checked-out tree, and that agent is your own arbitrary command. A single job that both checks out untrusted PR head and holds the bring-your-own-agent key would let a malicious fork exfiltrate that secret. So the reference splits into two workflows:

Workflow A: buildWorkflow B: post
Triggerpull_requestworkflow_run (on A completing)
Secretsnoneyes (GITHUB_TOKEN; bring-your-own-agent keys if needed)
Permissionscontents: read (no pull-requests: write)pull-requests: write, contents: read
Checks out PR head?yes, safe, nothing to stealnever
What it doesruns the gate on real PR code and uploads the review JSONdownloads the artifact, capability-gates, posts comments

Why it is safe: Workflow A runs untrusted PR code but holds no secrets. Workflow B holds secrets and write permission but never checks out or executes PR code; it only consumes the trusted artifact A produced. It even resolves the PR number from the artifact, not from the workflow_run head ref (which an attacker could influence).

Workflow A: build (gate plus advisory artifact)

Untrusted PR head, no secrets. The deterministic gate is authoritative; the advisory step is best-effort and uploaded separately.

name: Shipmoor PR (build)
on:
  pull_request:
permissions:
  contents: read            # NO pull-requests: write here
jobs:
  build-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0     # full history for the merge-base diff
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Install Shipmoor CLI
        run: |
          curl -fsSL https://dl.shipmoor.dev/install.sh | bash
          echo "$HOME/.shipmoor/bin" >> "$GITHUB_PATH"

      # The DETERMINISTIC GATE — its exit code is the authoritative pass/fail.
      - name: Shipmoor gate (deterministic, authoritative)
        run: shipmoor scan . --sarif --output shipmoor.sarif --fail-on high

      # Advisory review — `|| true` so a reviewer crash never flips the gate.
      # Runs in the no-secrets job, so use a LOCAL/offline agent here. A network
      # agent that needs a key must NOT run here — see "Per-agent placement".
      - name: Shipmoor review (advisory, non-gating)
        if: ${{ always() }}
        env:
          SHIPMOOR_AGENT: ${{ vars.SHIPMOOR_LOCAL_AGENT }}
        run: |
          if [[ -n "${SHIPMOOR_AGENT}" ]]; then
            shipmoor review . \
              --from "origin/${{ github.event.pull_request.base.ref }}" \
              --to "${{ github.event.pull_request.head.sha }}" \
              --agent "${SHIPMOOR_AGENT}" \
              --json --output "${RUNNER_TEMP}/shipmoor-review.json" \
              2> "${RUNNER_TEMP}/shipmoor-review.stderr" || true
          else
            echo '{"schema_version":"shipmoor.scan.v1","findings":[]}' > "${RUNNER_TEMP}/shipmoor-review.json"
          fi
          mkdir -p "${RUNNER_TEMP}/shipmoor-artifact"
          cp "${RUNNER_TEMP}/shipmoor-review.json" "${RUNNER_TEMP}/shipmoor-artifact/"
          echo "${{ github.event.pull_request.number }}" > "${RUNNER_TEMP}/shipmoor-artifact/pr-number.txt"

      - uses: actions/upload-artifact@v4
        with:
          name: shipmoor-review
          path: ${{ runner.temp }}/shipmoor-artifact/
          if-no-files-found: error

Workflow B: post (trusted, never executes PR code)

Holds secrets and pull-requests: write. Downloads A’s artifact, capability-gates on cli_pro, and posts. No actions/checkout of PR head appears anywhere.

name: Shipmoor PR (post)
on:
  workflow_run:
    workflows: ["Shipmoor PR (build)"]
    types: [completed]
permissions:
  pull-requests: write
  contents: read
jobs:
  post:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.event == 'pull_request' }}
    steps:
      - name: Download advisory review artifact
        uses: actions/download-artifact@v4
        with:
          name: shipmoor-review
          path: shipmoor-review-artifact
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}

      - name: Resolve PR number from artifact      # never the workflow_run ref
        id: pr
        run: |
          num="$(cat shipmoor-review-artifact/pr-number.txt | tr -dc '0-9')"
          [[ -z "$num" ]] && { echo "Nothing to post."; exit 0; }
          echo "number=$num" >> "$GITHUB_OUTPUT"

      - name: Install Shipmoor CLI                  # source-free capability probe
        if: ${{ steps.pr.outputs.number != '' }}
        run: |
          curl -fsSL https://dl.shipmoor.dev/install.sh | bash
          echo "$HOME/.shipmoor/bin" >> "$GITHUB_PATH"

      - name: Capability gate
        id: capgate
        if: ${{ steps.pr.outputs.number != '' }}
        run: |
          caps="$(shipmoor capabilities --json 2>/dev/null || echo '{}')"
          enabled="$(printf '%s' "$caps" | jq -r '.capabilities.cli_pro.enabled // false')"
          echo "review_enabled=$enabled" >> "$GITHUB_OUTPUT"

      - name: Post advisory review comments
        if: ${{ steps.pr.outputs.number != '' && steps.capgate.outputs.review_enabled == 'true' }}
        uses: actions/github-script@v7
        env:
          SHIPMOOR_REVIEW_JSON: shipmoor-review-artifact/shipmoor-review.json
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const prNumber = parseInt(${{ steps.pr.outputs.number }}, 10);
            context.issue.number = prNumber;
            context.payload.issue = { number: prNumber };
            const post = require('./shipmoor-review/ci/post_review.js');
            await post({ github, context, core });

The poster (shipmoor-review/ci/post_review.js) reads the canonical shipmoor.scan.v1 findings[] and posts them with a fixed advisory disclaimer: “These are advisory, model-derived suggestions. They do not affect the Shipmoor gate.” It never echoes raw agent stderr (which could leak prompts, source, or keys).

Capability gating (cli_pro) in CI

The poster gates on the cli_pro entitlement via a source-free probe:

shipmoor capabilities --json | jq -r '.capabilities.cli_pro.enabled'

If cli_pro is not enabled, the post step is skipped. The gate (Workflow A) still runs and still decides the build. A locked entitlement degrades the advisory layer gracefully; it never breaks the build.

Per-agent placement (forks)

Where the review runs depends on what your agent needs:

Agent profileNeeds a secret key?Reads the tree via tools?Where to run
Local or offline agentnoyes or noWorkflow A: no secret to leak; full tool access over PR head is fine.
Network agent, diff-only (reasons over the diff text only)yesnoWorkflow B over the diff text only: no tool execution against attacker-controlled source.
Network agent that reads the treeyesyesDo not run for fork PRs. Same-repo PRs only, or skip advisory for forks. Never combine PR-head checkout and the agent key in one job.

Secrets and egress notes

  • Bring-your-own-agent provider keys come from Actions secrets only, injected into the agent command’s own env. The only review-time network egress is your agent’s own provider call. The Shipmoor binary itself makes no review-time network call. On a locked-down runner, allowlist only your agent’s provider endpoint.
  • The poster needs only GITHUB_TOKEN plus pull-requests: write.

See also

Last updated on June 30, 2026

Was this article helpful?

Your response is saved on this device.