Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/cursor-approve/tests/test_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,38 @@ def pins(name):
self.assertEqual(pins("cursor-axis-base.yml"), pins("cursor-review.yml"))
self.assertEqual(len(pins("cursor-review.yml")), 2)

def test_cli_pin_bumper_edits_every_file_carrying_the_pin(self):
pin_re = re.compile(r"^ CURSOR_CLI_VERSION: ", re.MULTILINE)
carrying = []
for name in sorted(os.listdir(WORKFLOWS)):
if not name.endswith((".yml", ".yaml")):
continue
with open(os.path.join(WORKFLOWS, name), encoding="utf-8") as fh:
if pin_re.search(fh.read()):
carrying.append(f".github/workflows/{name}")
self.assertIn(".github/workflows/cursor-review.yml", carrying)
with open(os.path.join(WORKFLOWS, "bump-cursor-cli-pin.yml"), encoding="utf-8") as fh:
bumper = fh.read()
hint = ("every workflow carrying the Cursor CLI pin must be listed in bump-cursor-cli-pin.yml's "
"WORKFLOW_FILES (both the Discover and Move-the-pin steps) and its `git add` line")
listed = re.findall(r"^\s+WORKFLOW_FILES: (.+)$", bumper, re.MULTILINE)
self.assertEqual(len(listed), 2, "both the Discover and Move-the-pin steps declare WORKFLOW_FILES")
for value in listed:
self.assertEqual(sorted(value.split()), carrying, hint)
added = re.findall(r"^\s+git add (.+)$", bumper, re.MULTILINE)
self.assertEqual(len(added), 1, "the bumper stages the pin files in one `git add`")
for value in added:
self.assertEqual(sorted(value.split()), carrying, hint)
# And every carrier holds the SAME (version, sha256) pair, not just the
# two test_cli_pin_matches_cursor_review names.
pin_pair = re.compile(r"^ (CURSOR_CLI_(?:VERSION|SHA256)): (\S+)$", re.MULTILINE)
pairs = set()
for path in carrying:
with open(os.path.join(WORKFLOWS, os.path.basename(path)), encoding="utf-8") as fh:
pairs.add(tuple(pin_pair.findall(fh.read())))
self.assertEqual(len(pairs), 1, f"Cursor CLI pins diverge across {carrying}: {pairs}")
self.assertEqual(len(next(iter(pairs))), 2)



class GuardParity(unittest.TestCase):
Expand Down
2 changes: 1 addition & 1 deletion .github/cursor-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ the consolidated review's panel table. See [Panel integrity](#panel-integrity).
| [`auto-approve.py`](auto-approve.py) | Opt-in auto-approve (`approve_max_severity`): `decide` (in `post-review`) approves / requests changes / reaches no decision (withdrawing its own earlier approvals and leaving one standing REQUEST_CHANGES with the reasons and the next step, replaced by the next no-decision round and dismissed by the next approving one; none on a `capped` round, which withdraws the earlier one) per its fail-closed rules, then writes cursor-approve's status card for the outcome (via [`../cursor-approve/card.py`](../cursor-approve/card.py); no card for an author `approve_authors` does not list), and every review it posts links that card (on a PR's first round it creates a pending card just before the review, since a review cannot be edited to add the link later); `dismiss-stale` (the **Dismiss stale auto-approval** job) withdraws the bot's own approvals that are stale against the PR's live head and base (a push, a reopen, a retarget) — and every one of them while the PR carries `skip-cursor-review` or `needs-human-review` — plus its own standing REQUEST_CHANGES while the PR carries either label (re-read after the reviews are listed) or `approve_max_severity` is empty (then only once none of its approvals still stands), on every event of an open PR, whether or not `approve_max_severity` is still set (with `carry_forward_unchanged_diff`, it first re-approves the live head of a PR whose own changes are still the ones a round reviewed, from the merge base that approval recorded: `decide` and `approve-external` record it only when the compare API and the round's sentinel agree, `approval_merge_base`); `round-cap` (the **Round cap** job, ahead of the panel) enforces `max_rounds`. On an APPROVE that survived its post-write re-check, `decide` also resolves (re-reading each first) and then replies on the posting identity's own at-or-below-threshold threads nobody else commented in (nothing, if any live thread is above the threshold); [`build-ledger.py`](build-ledger.py) never counts that reply as an answer. `decide` also writes the `approve_gate` output, and `round-cap` feeds the `round` and `max_rounds` outputs. Reads open-thread severity through `gate-unresolved.py`'s query. See [`docs/callers/cursor-review.md`](../../docs/callers/cursor-review.md#auto-approve). |
| [`gate-unresolved.py`](gate-unresolved.py) | The opt-in blocking gate (`blocking: true` → the **Blocking gate** job): queries the PR's review threads and exits non-zero while any cursor-review finding thread is unresolved and non-outdated. Dropped from `cursor-review.yml` by accident in #31 and restored by BE-4691 — see [the blocking section](#optional-make-the-review-blocking). Double-billed: [`build-ledger.py`](build-ledger.py) also imports it for `CONSOLIDATED_MARKER`, the paging `reviewThreads` GraphQL query and the `iter_threads` / `is_cursor_thread` helpers, so the gate and the ledger can never disagree about which threads are ours. |
| [`slack-notify.sh`](slack-notify.sh) | Sends the start/complete Slack DMs to the triggerer (no-ops without a token). |
| [`install-cursor-cli.sh`](install-cursor-cli.sh) | Installs the Cursor agent CLI from the versioned, sha256-pinned release artifact — not `curl cursor.com/install \| bash`. Used by all three CLI-using jobs; the pin (`CURSOR_CLI_VERSION` / `CURSOR_CLI_SHA256`) lives in `cursor-review.yml`'s top-level `env:`. |
| [`install-cursor-cli.sh`](install-cursor-cli.sh) | Installs the Cursor agent CLI from the versioned, sha256-pinned release artifact — not `curl cursor.com/install \| bash`. Used by all three CLI-using jobs; the pin (`CURSOR_CLI_VERSION` / `CURSOR_CLI_SHA256`) lives in `cursor-review.yml`'s top-level `env:`, mirrored in `cursor-axis-base.yml`'s (cursor-approve's suite asserts they match); the weekly `bump-cursor-cli-pin.yml` moves both together. |
| [`build-ledger.py`](build-ledger.py) | Builds the **prior-review ledger** — what earlier rounds raised on this PR and how the author answered — and splices it into the panel/judge prompts. Untrusted prose is defanged twice over: fence-opening lines are rewritten, and continuation lines of quoted prose are prefixed ` |` so no imported text can sit at a field's indent and forge one. Also the prompt splicer, so the no-ledger path is byte-identical to the pre-ledger prompt. |
| [`fence-diff.py`](fence-diff.py) | Wraps the reviewed diff (plus the incremental hunks, and the judge's panel-findings block) in `=== BEGIN/END DIFF <nonce> ===` fences. The diff is attacker-authored PR bytes, so static literal fences are not a control; the nonce is what a PR cannot forge. Each prompt-build step mints its OWN nonce (`mint`), into a shell variable rather than a step `env:` or job output — Actions dumps a step's env map into the public run log, and a per-prompt value means a leak in one job cannot forge a fence in another. Copies the body through **byte for byte** — it never defangs or normalizes the payload. |
| [`incremental-diff.py`](incremental-diff.py) | Builds the incremental "hunks new since the last reviewed round" block as the difference between two PR patches (`build`), and verifies the result really is a subset of the reviewed diff (`check`) so the workflow can discard it if it is not. Never a commit range: `git diff LAST_REVIEWED...HEAD` carries every commit a merge of the base branch brought in — see [the ledger section](#the-prior-review-ledger-and-the-repeat-policy). |
Expand Down
108 changes: 72 additions & 36 deletions .github/workflows/bump-cursor-cli-pin.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
name: Bump Cursor CLI pin

# Keep the Cursor CLI pin in cursor-review.yml from going stale silently.
# Keep the Cursor CLI pin in cursor-review.yml and cursor-axis-base.yml from
# going stale silently.
#
# BE-5869 replaced `curl https://cursor.com/install | bash` with a versioned,
# sha256-pinned artifact (see .github/cursor-review/install-cursor-cli.sh). That
# removed the mutable-bootstrap supply-chain risk but introduced a maintenance
# one: nothing moves the pin, so the panel would run an ever-older CLI until
# someone noticed. This job notices instead — weekly it reads the version Cursor
# currently ships, and when it differs from the pin it downloads that version's
# linux/x64 artifact, computes its digest, and opens a two-line PR moving
# CURSOR_CLI_VERSION + CURSOR_CLI_SHA256. Nothing auto-merges: a human reviews
# linux/x64 artifact, computes its digest, and opens a PR moving
# CURSOR_CLI_VERSION + CURSOR_CLI_SHA256 in BOTH files that carry the pin
# (cursor-review.yml, and cursor-axis-base.yml since the cursor-approve axes
# install the same CLI). Nothing auto-merges: a human reviews
# the digest, which is the whole point (see the trust-anchor note below).
#
# Merging that PR touches .github/workflows/cursor-review.yml, which is exactly
# the path filter bump-cursor-review-callers.yml watches — so the SHA-pinned
# caller fleet rolls forward on its own. No new fleet plumbing here.
# Merging that PR touches .github/workflows/cursor-review.yml and
# .github/workflows/cursor-axis-base.yml, both already in
# bump-cursor-review-callers.yml's single `push.paths` filter — so the one
# SHA-pinned caller fleet rolls forward on its own, exactly as before (there is
# deliberately no separate cursor-approve fleet; see that file's header). No new
# fleet plumbing here. The two pins must stay equal (cursor-approve's
# test_card.py asserts it), so the bumper reads and moves them together; the
# same suite asserts WORKFLOW_FILES and the `git add` below list every file
# carrying the pin.
#
# Modelled on bump-cursor-review-callers.yml: same main-only job guard, same
# non-cancelling concurrency around a stable shared branch, same App-token
Expand Down Expand Up @@ -73,7 +82,7 @@ jobs:
- name: Discover the version Cursor currently ships
id: discover
env:
WORKFLOW_FILE: .github/workflows/cursor-review.yml
WORKFLOW_FILES: .github/workflows/cursor-review.yml .github/workflows/cursor-axis-base.yml
run: |
set -euo pipefail

Expand Down Expand Up @@ -112,10 +121,34 @@ jobs:

# Anchored read of the pin: `^ CURSOR_CLI_VERSION: ` matches the
# top-level env key and nothing else (the in-job references to
# $CURSOR_CLI_VERSION are indented far deeper).
old_version=$(sed -nE 's/^ CURSOR_CLI_VERSION: (.*)$/\1/p' "$WORKFLOW_FILE" | head -1 || true)
# $CURSOR_CLI_VERSION are indented far deeper). Every file carrying the
# pin must agree on BOTH the version and the digest: a divergence means
# someone hand-moved one of them, and the bumper fails loud rather than
# papering over it. A digest mismatch on an equal version is checked
# here, before the no-change exit, because it is otherwise invisible
# to a run whose version is already current. Never auto-reconciled:
# which digest is right is the reviewer's call (see the trust anchor).
read -ra files <<<"$WORKFLOW_FILES"
old_version=""
old_sha256=""
for f in "${files[@]}"; do
v=$(sed -nE 's/^ CURSOR_CLI_VERSION: (.*)$/\1/p' "$f" | head -1 || true)
Comment thread
mattmillerai marked this conversation as resolved.
d=$(sed -nE 's/^ CURSOR_CLI_SHA256: (.*)$/\1/p' "$f" | head -1 || true)
if [ -z "$v" ] || [ -z "$d" ]; then
echo "::error::No ' CURSOR_CLI_VERSION: ' / ' CURSOR_CLI_SHA256: ' line in ${f} — the pin moved or was removed; this workflow needs updating."
exit 1
fi
echo "pinned in ${f}: ${v} (sha256 ${d})"
if [ -z "$old_version" ]; then
old_version="$v"
Comment thread
mattmillerai marked this conversation as resolved.
old_sha256="$d"
elif [ "$v" != "$old_version" ] || [ "$d" != "$old_sha256" ]; then
echo "::error::Pins disagree across ${WORKFLOW_FILES} ('${old_version}' / ${old_sha256} vs '${v}' / ${d} in ${f}) — reconcile by hand before the bumper can move them."
exit 1
fi
done
if [ -z "$old_version" ]; then
echo "::error::No ' CURSOR_CLI_VERSION: ' line in ${WORKFLOW_FILE} — the pin moved or was removed; this workflow needs updating."
echo "::error::WORKFLOW_FILES is empty — nothing to read the pin from."
exit 1
fi

Expand Down Expand Up @@ -250,40 +283,43 @@ jobs:
- name: Move the pin
if: steps.discover.outputs.changed == 'true'
env:
WORKFLOW_FILE: .github/workflows/cursor-review.yml
WORKFLOW_FILES: .github/workflows/cursor-review.yml .github/workflows/cursor-axis-base.yml
NEW_VERSION: ${{ steps.discover.outputs.new_version }}
NEW_SHA256: ${{ steps.digest.outputs.new_sha256 }}
run: |
set -euo pipefail

# Both substitutions are anchored to the top-level env keys, and each
# must match EXACTLY one line — anything else means cursor-review.yml
# must match EXACTLY one line per file — anything else means that file
# was restructured and a blind sed could corrupt it.
for key in CURSOR_CLI_VERSION CURSOR_CLI_SHA256; do
n=$(grep -cE "^ ${key}: " "$WORKFLOW_FILE" || true)
if [ "$n" != "1" ]; then
echo "::error::Expected exactly one ' ${key}: ' line in ${WORKFLOW_FILE}, found ${n}."
exit 1
fi
done
read -ra files <<<"$WORKFLOW_FILES"
for f in "${files[@]}"; do
for key in CURSOR_CLI_VERSION CURSOR_CLI_SHA256; do
n=$(grep -cE "^ ${key}: " "$f" || true)
if [ "$n" != "1" ]; then
echo "::error::Expected exactly one ' ${key}: ' line in ${f}, found ${n}."
exit 1
fi
done

before="${RUNNER_TEMP}/cursor-review.before.yml"
before="${RUNNER_TEMP}/$(basename "$f").before"

cp "$WORKFLOW_FILE" "$before"
sed -i -E "s|^ CURSOR_CLI_VERSION: .*\$| CURSOR_CLI_VERSION: ${NEW_VERSION}|" "$WORKFLOW_FILE"
if cmp -s "$before" "$WORKFLOW_FILE"; then
echo "::error::The CURSOR_CLI_VERSION substitution changed nothing."
exit 1
fi
cp "$f" "$before"
sed -i -E "s|^ CURSOR_CLI_VERSION: .*\$| CURSOR_CLI_VERSION: ${NEW_VERSION}|" "$f"
if cmp -s "$before" "$f"; then
echo "::error::The CURSOR_CLI_VERSION substitution changed nothing in ${f}."
exit 1
fi

cp "$WORKFLOW_FILE" "$before"
sed -i -E "s|^ CURSOR_CLI_SHA256: .*\$| CURSOR_CLI_SHA256: ${NEW_SHA256}|" "$WORKFLOW_FILE"
if cmp -s "$before" "$WORKFLOW_FILE"; then
echo "::error::The CURSOR_CLI_SHA256 substitution changed nothing."
exit 1
fi
cp "$f" "$before"
sed -i -E "s|^ CURSOR_CLI_SHA256: .*\$| CURSOR_CLI_SHA256: ${NEW_SHA256}|" "$f"
if cmp -s "$before" "$f"; then
echo "::error::The CURSOR_CLI_SHA256 substitution changed nothing in ${f}."
exit 1
fi
done

git --no-pager diff -- "$WORKFLOW_FILE"
git --no-pager diff -- "${files[@]}"

- name: Generate Cloud Code Bot token
id: token
Expand Down Expand Up @@ -320,7 +356,7 @@ jobs:
git config user.name 'cursor-cli-pin bumper'
git config user.email 'cursor-cli-pin-bumper@users.noreply.github.com'
git checkout -B "$BRANCH"
git add .github/workflows/cursor-review.yml
git add .github/workflows/cursor-review.yml .github/workflows/cursor-axis-base.yml
git commit -m "ci(cursor-review): bump Cursor CLI pin to ${NEW_VERSION}"

# Force-reset the stable branch so its diff is rebuilt from the current
Expand All @@ -338,14 +374,14 @@ jobs:
# command substitutions — that is exactly why they are single-quoted.
# shellcheck disable=SC2016
{
printf '%s\n\n' 'Moves the Cursor CLI pin `cursor-review.yml` installs from (BE-5869).'
printf '%s\n\n' 'Moves the Cursor CLI pin that `cursor-review.yml` and `cursor-axis-base.yml` install from (BE-5869).'
printf '| | |\n|---|---|\n'
printf '| Version | `%s` → `%s` |\n' "$OLD_VERSION" "$NEW_VERSION"
printf '| sha256 (linux/x64 `agent-cli-package.tar.gz`) | `%s` |\n' "$NEW_SHA256"
printf '| nixpkgs cross-check | %s |\n' "$NIXPKGS_STATUS"
printf '| smoke test | passed — `install-cursor-cli.sh` installed this artifact and the binary self-reported `%s` |\n\n' "$NEW_VERSION"
printf '**You are the trust anchor for this digest.** Cursor publishes no checksums for these artifacts, so the sha256 above is simply what the CDN served during [this run](%s) — trust-on-first-use. Corroborate it before merging (the nixpkgs row above, or a second independent download); nothing downstream can.\n\n' "$RUN_URL"
printf '%s\n\n' 'Merging this bumps `cursor-review.yml`, which triggers `bump-cursor-review-callers.yml` and rolls the SHA-pinned caller fleet forward.'
printf '%s\n\n' 'Merging this bumps `cursor-review.yml` and `cursor-axis-base.yml`; both are in the path filter of `bump-cursor-review-callers.yml`, so the single SHA-pinned caller fleet rolls forward once, as before.'
printf '%s\n' '_Opened by the `bump-cursor-cli-pin` workflow._'
} > "$body"

Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/test-cursor-review-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ on:
# (secret isolation, the guard byte-copy, the CLI pin).
- '.github/workflows/cursor-approve.yml'
- '.github/workflows/cursor-axis-base.yml'
# The cursor-approve suite asserts bump-cursor-cli-pin.yml lists EVERY
# workflow carrying the CLI pin, so any workflow gaining (or losing) the
# pin must trigger it — hence all of them (both extensions the suite
# scans), not just the bumper.
- '.github/workflows/*.yml'
Comment thread
mattmillerai marked this conversation as resolved.
- '.github/workflows/*.yaml'
- '.github/workflows/axis-*.yml'
# test_caller_doc_pattern.py reads the caller snippets out of these
# pages and pins their `if:` guards; the regression it guards is a
Expand All @@ -58,6 +64,12 @@ on:
# (secret isolation, the guard byte-copy, the CLI pin).
- '.github/workflows/cursor-approve.yml'
- '.github/workflows/cursor-axis-base.yml'
# The cursor-approve suite asserts bump-cursor-cli-pin.yml lists EVERY
# workflow carrying the CLI pin, so any workflow gaining (or losing) the
# pin must trigger it — hence all of them (both extensions the suite
# scans), not just the bumper.
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '.github/workflows/axis-*.yml'
# test_caller_doc_pattern.py reads the caller snippets out of these
# pages and pins their `if:` guards; the regression it guards is a
Expand Down
Loading