diff --git a/.github/actions/inject-linked-client-pr/action.yml b/.github/actions/inject-linked-client-pr/action.yml new file mode 100644 index 00000000..2464b4bf --- /dev/null +++ b/.github/actions/inject-linked-client-pr/action.yml @@ -0,0 +1,263 @@ +name: Inject linked miden-client PR +description: > + If the current PR's body contains a "Client PR: #N" marker, append a + [patch] block to Cargo.toml that points the miden-client dep at the + linked PR's head branch. Runs only on `pull_request` events (silent + no-op on `push`). On the first run per PR, posts a sticky comment + describing what was patched. Fails fast and loudly if the linked PR + is closed without merge. + + Marker syntax (anywhere in the body, first match wins): + Client PR: #1234 + Client PR: 0xMiden/miden-client#1234 (cross-repo / forks) + + This action keeps Cargo.toml clean on disk for committers — they + never need to hand-edit a patch block. Local-dev parity is provided + by scripts/dev-with-client-pr.sh, which writes the same block + underneath a marked-out region you can clear with --clear. + +inputs: + github-token: + description: Token for gh CLI calls (defaults to GITHUB_TOKEN). + required: false + default: ${{ github.token }} + comment: + description: > + Whether to post/update a sticky PR comment summarizing the patch. + Default false: this action is typically called from many cargo-using + jobs in the same workflow run, and posting the same comment from + each is wasteful. Pass `comment: true` from exactly one designated + job (the first one that runs cargo, conventionally `build-wasm`). + The sticky-comment header dedups across calls, so over-firing is + correct but spammy. + required: false + default: "false" + +outputs: + patched: + description: "true if a patch was injected, false otherwise." + value: ${{ steps.parse.outputs.patched }} + client_pr: + description: The linked PR number, or empty. + value: ${{ steps.parse.outputs.num }} + client_repo: + description: The linked PR's repo (owner/repo), or empty. + value: ${{ steps.parse.outputs.repo }} + client_head_sha: + description: The linked PR's head SHA, or empty. + value: ${{ steps.parse.outputs.head_sha }} + +runs: + using: composite + steps: + - id: parse + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + EVENT_NAME: ${{ github.event_name }} + # github-script can't pass body cleanly through outputs (newlines), so + # we read it from a file the API gives us via gh. + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + # Skip on push / schedule / workflow_dispatch. + if [ "$EVENT_NAME" != "pull_request" ] && [ "$EVENT_NAME" != "pull_request_target" ]; then + echo "patched=false" >> "$GITHUB_OUTPUT" + echo "::notice title=inject-linked-client-pr::Skipping on event=$EVENT_NAME (only pull_request triggers patch injection)." + exit 0 + fi + + if [ -z "${PR_NUMBER:-}" ]; then + echo "patched=false" >> "$GITHUB_OUTPUT" + echo "::notice title=inject-linked-client-pr::No PR number in event payload." + exit 0 + fi + + # Pull the body via API (avoids context interpolation hazards with + # arbitrary user input in PR descriptions). + body=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""') + + # Marker: "Client PR:" optionally followed by "/" then "#N". + # First match wins; case-insensitive. + marker=$(printf '%s' "$body" \ + | grep -ioE '^[[:space:]]*Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' \ + | head -1 || true) + if [ -z "$marker" ]; then + echo "patched=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + repo=$(printf '%s' "$marker" | grep -oE '[0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+' | head -1 || echo "0xMiden/miden-client") + num=$(printf '%s' "$marker" | grep -oE '[0-9]+$') + + # Resolve head + state. + meta=$(gh api "repos/${repo}/pulls/${num}" \ + --jq '"\(.head.repo.owner.login)\t\(.head.repo.name)\t\(.head.ref)\t\(.head.sha)\t\(.state)\t\(.merged)"' 2>/dev/null \ + || { echo "::error title=inject-linked-client-pr::Failed to fetch ${repo}#${num}. Does the PR exist? Is GH_TOKEN scoped to read it?"; exit 1; }) + + IFS=$'\t' read -r head_owner head_repo head_ref head_sha state merged <<< "$meta" + + # Closed without merge → loud failure. (A merged PR is acceptable — + # it just means the linked branch may be stale; cargo will resolve + # to the head sha which still exists.) + if [ "$state" != "OPEN" ] && [ "$merged" != "true" ]; then + { + echo "## ❌ Linked client PR is closed without merge" + echo + echo "PR description marker: \`Client PR: ${repo}#${num}\`" + echo + echo "[\`${repo}#${num}\`](https://github.com/${repo}/pull/${num}) is in state \`${state}\` (not merged)." + echo + echo "Either re-open the linked PR, point \`Client PR:\` at a different one, or remove the marker (and add the dep retarget by hand)." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error title=Linked PR closed::${repo}#${num} is closed without merge." + exit 1 + fi + + echo "patched=true" >> "$GITHUB_OUTPUT" + echo "repo=${repo}" >> "$GITHUB_OUTPUT" + echo "num=${num}" >> "$GITHUB_OUTPUT" + echo "head_owner=${head_owner}" >> "$GITHUB_OUTPUT" + echo "head_repo=${head_repo}" >> "$GITHUB_OUTPUT" + echo "head_ref=${head_ref}" >> "$GITHUB_OUTPUT" + echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + echo "state=${state}" >> "$GITHUB_OUTPUT" + echo "merged=${merged}" >> "$GITHUB_OUTPUT" + + - if: steps.parse.outputs.patched == 'true' + shell: bash + env: + REPO: ${{ steps.parse.outputs.repo }} + NUM: ${{ steps.parse.outputs.num }} + HEAD_OWNER: ${{ steps.parse.outputs.head_owner }} + HEAD_REPO: ${{ steps.parse.outputs.head_repo }} + HEAD_REF: ${{ steps.parse.outputs.head_ref }} + HEAD_SHA: ${{ steps.parse.outputs.head_sha }} + MERGED: ${{ steps.parse.outputs.merged }} + run: | + set -euo pipefail + + # We rewrite the miden-client (and miden-client-sqlite-store, if + # present) dep lines IN PLACE rather than emitting a [patch] block. + # + # Why not [patch]? Cargo's [patch.""] section needs the patch + # source to be DIFFERENT from the original source. Web-sdk's `next` + # pins `miden-client` to `https://github.com/0xMiden/miden-client.git` + # at branch=next; pointing the same URL at a different branch via + # [patch.""] errors with `patches must point to different + # sources`. [patch.crates-io] also doesn't apply on `next` because + # the dep isn't a crates.io source there. In-place rewrite covers + # both branches uniformly. + # + # The block is bracketed by markers so it's clear at a glance that + # this Cargo.toml is in a CI-mutated state — and so a future + # cleanup can locate and revert it. + url="https://github.com/${HEAD_OWNER}/${HEAD_REPO}.git" + python3 <miden-client(?:-sqlite-store)?)\s*=\s*(?P.+)$') + + captured = [] + patched_lines = [] + for ln in lines: + m = dep_re.match(ln) + if not m: + continue + captured.append(ln.rstrip('\n')) + df = ', default-features = false' if 'default-features = false' in m.group('rhs') else '' + patched_lines.append(f"{m.group('name'):<25} = {{ branch = \"{ref}\"{df}, git = \"{url}\" }}") + + if not captured: + raise SystemExit("No miden-client dep line found in Cargo.toml — nothing to patch.") + + out = [] + inserted = False + for ln in lines: + if not inserted and dep_re.match(ln): + out.append(mark_begin + "\n") + out.append(f"# Source: PR description marker \"Client PR: ${REPO}#${NUM}\".\n") + out.append(f"# Pin tip: ${HEAD_SHA} on ${HEAD_OWNER}/${HEAD_REPO}@${HEAD_REF}.\n") + out.append(f"# Merged: ${MERGED}. Runner-local override; never committed.\n") + out.append("# Original lines (do not edit):\n") + for c in captured: + out.append("# " + c + "\n") + out.append(mark_end + "\n") + for p in patched_lines: + out.append(p + "\n") + inserted = True + continue + if dep_re.match(ln): + continue + out.append(ln) + + with open(path, 'w') as f: + f.writelines(out) + PY + + # cargo update -p X requires X to be in the dep tree. main only + # pins miden-client (crates.io until the rewrite above swaps it + # to git+branch); next adds miden-client-sqlite-store. Build the + # -p list dynamically so this works on both branches. + update_args="-p miden-client" + if cargo pkgid -p miden-client-sqlite-store >/dev/null 2>&1; then + update_args="$update_args -p miden-client-sqlite-store" + fi + # shellcheck disable=SC2086 + cargo update $update_args --quiet + + # Surface the patch state in the job summary regardless of comment-on/off. + { + echo "## 🔗 Linked client PR injected" + echo + echo "| Field | Value |" + echo "|---|---|" + echo "| Marker | \`Client PR: ${REPO}#${NUM}\` |" + echo "| Head | \`${HEAD_OWNER}/${HEAD_REPO}@${HEAD_REF}\` |" + echo "| Pin | \`${HEAD_SHA}\` |" + echo "| Merged upstream | \`${MERGED}\` |" + } >> "$GITHUB_STEP_SUMMARY" + + # Comment lifecycle (only fires when comment=true, by convention from a + # single designated job per workflow run): + # - patched=true → upsert the sticky comment via the same `header`, + # so re-runs UPDATE the existing comment in place (no duplicates). + # - patched=false → DELETE any prior comment with the same header. + # This keeps the invariant "at most one linked-client-pr comment + # on any PR": if the marker is added then removed, the stale + # comment doesn't linger. + - if: inputs.comment == 'true' && steps.parse.outputs.patched == 'true' + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.4 + with: + header: linked-client-pr + message: | + 🔗 **Linked client PR**: [`${{ steps.parse.outputs.repo }}#${{ steps.parse.outputs.num }}`](https://github.com/${{ steps.parse.outputs.repo }}/pull/${{ steps.parse.outputs.num }}) + + | Field | Value | + |---|---| + | Patched at | `${{ steps.parse.outputs.head_owner }}/${{ steps.parse.outputs.head_repo }}@${{ steps.parse.outputs.head_ref }}` | + | Pin (head sha) | `${{ steps.parse.outputs.head_sha }}` | + | Upstream state | `${{ steps.parse.outputs.state }}` (merged: `${{ steps.parse.outputs.merged }}`) | + + This run is testing against the linked PR's head. The published artifact will use the canonical `miden-client` source — CI on `main`/`next` does **not** auto-patch. + + Local-dev parity: + ``` + scripts/dev-with-client-pr.sh ${{ steps.parse.outputs.num }} # apply the same patch locally + scripts/dev-with-client-pr.sh --clear # remove it before commit + ``` + + - if: inputs.comment == 'true' && steps.parse.outputs.patched != 'true' + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.4 + with: + header: linked-client-pr + delete: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ac5d24c..eabd6673 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,15 @@ jobs: SCCACHE_GHA_ENABLED: "true" steps: - uses: actions/checkout@v6 + # Auto-patch miden-client dep against any "Client PR: #N" marker in + # the PR description. This is the *one* job per workflow run that + # opts into posting the sticky PR comment about the patch — every + # other cargo-using job re-runs the action with comment=false (the + # default) so we keep the strict 0-or-1 comment invariant. + - name: Inject linked miden-client PR + uses: ./.github/actions/inject-linked-client-pr + with: + comment: "true" - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - name: Install Rust toolchain diff --git a/.github/workflows/check-linked-client-pr.yml b/.github/workflows/check-linked-client-pr.yml new file mode 100644 index 00000000..5aef97e2 --- /dev/null +++ b/.github/workflows/check-linked-client-pr.yml @@ -0,0 +1,168 @@ +name: Linked client PR ready + +# Mergeability gate for PRs that carry a `Client PR: #N` marker. +# +# The auto-patch action (.github/actions/inject-linked-client-pr) makes +# the PR's CI build green against the linked PR's head, so the diff stays +# clean. But the *published* artifact, after merge, builds from canonical +# refs: +# - PRs targeting `main` build against `miden-client = "X.Y.Z"` from +# crates.io. After merge, main's CI is red until miden-client publishes +# a release that includes the linked PR's commit. +# - PRs targeting `next` build against `miden-client@next` (git source). +# After merge, next's CI is red until the linked PR is merged into +# miden-client's `next`. +# +# This workflow keeps a check status on the PR that goes green only when +# the linked PR has reached the right state for the target branch: +# - `main` target → linked PR must be merged AND released to crates.io +# at a version satisfying web-sdk's version requirement. +# - `next` target → linked PR must be merged into miden-client/next. +# +# Until that's true, the check stays pending — branch protection can be +# configured to require this check, blocking merge. + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + # Re-evaluate on a schedule so the check goes green when upstream catches + # up, without requiring a push to this PR. + schedule: + - cron: "*/15 * * * *" + +permissions: + contents: read + pull-requests: read + checks: write + +concurrency: + group: linked-client-pr-ready @ ${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + scheduled-fanout: + # The schedule trigger fires once per repo; we need to fan out across + # every open PR that carries a Client PR marker so each gets its check + # re-evaluated. On `pull_request` events we skip this and run `gate` + # directly with the event's PR. + if: github.event_name == 'schedule' + runs-on: ubuntu-24.04 + outputs: + prs: ${{ steps.list.outputs.prs }} + steps: + - id: list + env: + GH_TOKEN: ${{ github.token }} + run: | + prs=$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --state open --search 'in:body "Client PR:"' \ + --json number --jq 'map(.number)') + echo "prs=${prs:-[]}" >> "$GITHUB_OUTPUT" + + gate: + needs: [scheduled-fanout] + if: | + always() && + (github.event_name == 'pull_request' || + (github.event_name == 'schedule' && needs.scheduled-fanout.outputs.prs != '[]')) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + pr: ${{ github.event_name == 'schedule' && fromJSON(needs.scheduled-fanout.outputs.prs) || fromJSON(format('[{0}]', github.event.pull_request.number)) }} + steps: + - uses: actions/checkout@v6 + + - name: Evaluate linked-client-pr readiness for PR #${{ matrix.pr }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUM: ${{ matrix.pr }} + run: | + set -euo pipefail + + read -r body base_ref state <<<"$(gh api repos/$GITHUB_REPOSITORY/pulls/$PR_NUM \ + --jq '"\(.body // "")\t\(.base.ref)\t\(.state)"')" + + # Skip closed PRs entirely. + if [ "$state" != "open" ] && [ "$state" != "OPEN" ]; then + echo "PR #$PR_NUM is $state — skipping." + exit 0 + fi + + # Parse marker; absent → check passes (no gate needed). + marker=$(printf '%s' "$body" | grep -ioE '^[[:space:]]*Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || true) + if [ -z "$marker" ]; then + echo "No Client PR marker on PR #$PR_NUM — check passes." + gh api repos/$GITHUB_REPOSITORY/statuses/$(gh api repos/$GITHUB_REPOSITORY/pulls/$PR_NUM --jq .head.sha) \ + -X POST -f state=success \ + -f context="linked-client-pr-ready" \ + -f description="No linked client PR — nothing to gate." >/dev/null + exit 0 + fi + + repo=$(printf '%s' "$marker" | grep -oE '[0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+' | head -1 || echo "0xMiden/miden-client") + num=$(printf '%s' "$marker" | grep -oE '[0-9]+$') + head_sha=$(gh api repos/$GITHUB_REPOSITORY/pulls/$PR_NUM --jq .head.sha) + + # Pull the upstream PR's state. + read -r merged merge_commit_sha <<<"$(gh api repos/$repo/pulls/$num --jq '"\(.merged)\t\(.merge_commit_sha // "")"')" + + set_status() { + local s="$1" desc="$2" + gh api "repos/$GITHUB_REPOSITORY/statuses/$head_sha" -X POST \ + -f state="$s" \ + -f context="linked-client-pr-ready" \ + -f description="$desc" \ + -f target_url="https://github.com/${repo}/pull/${num}" >/dev/null + } + + # Step 1: linked PR must be merged. + if [ "$merged" != "true" ]; then + set_status pending "Linked ${repo}#${num} not merged yet." + echo "::notice::Gate PENDING — ${repo}#${num} not merged yet." + exit 0 + fi + + # Step 2: target-branch-specific readiness. + case "$base_ref" in + next) + # next-target → require the merge_commit_sha to be reachable + # from miden-client's `next` branch. + next_sha=$(gh api repos/$repo/git/ref/heads/next --jq .object.sha) + if [ -z "$merge_commit_sha" ]; then + set_status pending "Linked ${repo}#${num} merged but merge_commit_sha unknown." + exit 0 + fi + # Use the compare API: if merge_commit_sha is an ancestor of next, status is "ahead" or "identical". + status_field=$(gh api "repos/$repo/compare/${merge_commit_sha}...${next_sha}" --jq .status) + case "$status_field" in + ahead|identical) set_status success "Linked ${repo}#${num} merged into ${repo}/next." ;; + *) set_status pending "Linked ${repo}#${num} merged, not yet on ${repo}/next." ;; + esac + ;; + main) + # main-target → require miden-client to have a published release + # whose tag commit reaches merge_commit_sha. We approximate this + # by checking whether merge_commit_sha is reachable from the + # latest release tag. + latest_tag=$(gh api repos/$repo/releases/latest --jq .tag_name 2>/dev/null || echo "") + if [ -z "$latest_tag" ]; then + set_status pending "Linked ${repo}#${num} merged; no release tag visible on ${repo} yet." + exit 0 + fi + latest_tag_sha=$(gh api repos/$repo/git/ref/tags/$latest_tag --jq .object.sha 2>/dev/null || echo "") + if [ -z "$latest_tag_sha" ] || [ -z "$merge_commit_sha" ]; then + set_status pending "Could not resolve tag SHA for ${repo}@${latest_tag}." + exit 0 + fi + status_field=$(gh api "repos/$repo/compare/${merge_commit_sha}...${latest_tag_sha}" --jq .status) + case "$status_field" in + ahead|identical) set_status success "Linked ${repo}#${num} included in release ${latest_tag}." ;; + *) set_status pending "Linked ${repo}#${num} merged; not yet in a ${repo} release (latest: ${latest_tag})." ;; + esac + ;; + *) + # Unknown target branch — pass through. + set_status success "Base branch '${base_ref}' has no readiness rule." + ;; + esac diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 548ab2d4..30efa3ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,11 @@ jobs: SCCACHE_GHA_ENABLED: "true" steps: - uses: actions/checkout@v6 + # Auto-patch miden-client dep against any "Client PR: #N" marker in + # the PR description. Sticky comment is owned by build.yml's + # build-wasm job (single comment per PR run); we don't post here. + - name: Inject linked miden-client PR + uses: ./.github/actions/inject-linked-client-pr - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - name: Install Rust (needed for WASM build) @@ -196,6 +201,11 @@ jobs: SCCACHE_GHA_ENABLED: "true" steps: - uses: actions/checkout@v6 + # Auto-patch miden-client dep against any "Client PR: #N" marker in + # the PR description. Sticky comment is owned by build.yml's + # build-wasm job (single comment per PR run); we don't post here. + - name: Inject linked miden-client PR + uses: ./.github/actions/inject-linked-client-pr - name: Cleanup large tools for build space uses: ./.github/actions/cleanup-runner - name: Install Rust (needed for WASM build) diff --git a/CLAUDE.md b/CLAUDE.md index 8661f102..69d74c0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,38 @@ When in doubt, drop the entry and apply `no changelog`. A missing entry the revi PRs that touch the WASM/JS boundary often need a synchronized PR in miden-client — bump the workspace dep and verify the integration tests still pass. +### Linking a web-sdk PR to an in-flight miden-client PR + +When a web-sdk PR depends on Rust changes that haven't been released yet (i.e. the upstream PR on miden-client is still open), add a marker line to the web-sdk PR description: + +``` +Client PR: #2080 +``` +or, for forks / cross-repo, +``` +Client PR: 0xMiden/miden-client#2080 +``` + +CI picks up the marker via `.github/actions/inject-linked-client-pr`, appends a `[patch]` block to `Cargo.toml` (runner-local — never committed) pointing the workspace `miden-client` dep at the linked PR's head, refreshes `Cargo.lock`, and posts a sticky comment on the web-sdk PR summarizing what was patched. There is at most one such comment per PR (the action deletes it if the marker is later removed). + +Local-dev parity: + +```bash +# Apply the same patch to your working tree (reads the marker from the current branch's PR body): +scripts/dev-with-client-pr.sh + +# Or pass an explicit number / cross-repo target: +scripts/dev-with-client-pr.sh 2080 +scripts/dev-with-client-pr.sh koookxbt/miden-client#1965 + +# Strip the patch before committing: +scripts/dev-with-client-pr.sh --clear +``` + +The script writes a marker-wrapped `[patch]` block at the bottom of `Cargo.toml`. A pre-commit hook (`lefthook.yml`) blocks any commit while the markers are present, so you can't ship the local override by accident. + +**Mergeability gate.** A separate workflow (`.github/workflows/check-linked-client-pr.yml`) keeps a `linked-client-pr-ready` check on the PR. It stays *pending* while the linked client PR isn't merged-and-reachable from web-sdk's target branch's canonical refs (miden-client `next` for `next`-targeted PRs, or the latest miden-client release tag for `main`-targeted PRs). It re-evaluates every 15 minutes, so the check goes green automatically once upstream catches up — no need to push to the PR. Configure branch protection to require this check before merge. + ## Contributing checklist 1. `make lint` clean. diff --git a/lefthook.yml b/lefthook.yml index 57781c1d..f3fd9f38 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,28 @@ pre-commit: commands: + # Guard against committing the auto-injected linked-client-pr block. + # See scripts/dev-with-client-pr.sh / .github/actions/inject-linked-client-pr. + no-linked-client-pr-block: + glob: "Cargo.toml" + run: | + if grep -qF ">>>>>>> linked-client-pr" Cargo.toml; then + cat <&2 + ❌ Cargo.toml has an auto-injected linked-client-pr block. + + That block is meant for local-dev / CI only — it pins miden-client to a + feature branch on github, which would lock the published web-sdk + artifact against an unreleased upstream commit if it landed on main/next. + + Clear it before committing: + scripts/dev-with-client-pr.sh --clear + + If you need the patch state in CI, add 'Client PR: #' to the PR + description instead — the workflow will inject the same block at run-time + without requiring it to be committed. + MSG + exit 1 + fi lint-staged: run: pnpm exec lint-staged stage_fixed: true diff --git a/scripts/dev-with-client-pr.sh b/scripts/dev-with-client-pr.sh new file mode 100755 index 00000000..5b194b70 --- /dev/null +++ b/scripts/dev-with-client-pr.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Local-dev mirror of .github/actions/inject-linked-client-pr. +# +# Appends a [patch] block to Cargo.toml that retargets miden-client (and +# miden-client-sqlite-store) at a linked miden-client PR's head branch, +# wrapped in begin/end markers so it can be removed cleanly with --clear. +# A pre-commit hook (lefthook.yml) blocks committing while the marked +# block is present, so you can't accidentally ship the patch. +# +# Usage: +# scripts/dev-with-client-pr.sh # auto-detect: read 'Client PR: #N' from current branch's PR body +# scripts/dev-with-client-pr.sh 1234 # use miden-client#1234 +# scripts/dev-with-client-pr.sh 0xMiden/miden-client#1234 # explicit cross-repo form +# scripts/dev-with-client-pr.sh --clear # remove the patch block + restore Cargo.lock +# +# Requirements: gh (for PR lookup), cargo, awk. + +set -euo pipefail + +CARGO_TOML="$(git rev-parse --show-toplevel)/Cargo.toml" +MARK_BEGIN="# >>>>>>> linked-client-pr (auto-injected by scripts/dev-with-client-pr.sh) >>>>>>>" +MARK_END="# <<<<<<< linked-client-pr <<<<<<<" + +# We CAN'T use [patch.""] when the patched dep URL matches the +# original dep URL — Cargo errors with `patches must point to different +# sources`. Instead we rewrite the dep line in place and stash the +# original in a marker block so --clear can restore it byte-for-byte. + +clear_block() { + if ! grep -qF "$MARK_BEGIN" "$CARGO_TOML"; then + return 0 + fi + # Restore originals: extract everything between the markers (lines + # starting with "# " carry the original dep line — strip the prefix), + # then drop both the marker block and any auto-injected dep lines that + # follow it. The format the apply step writes is: + # $MARK_BEGIN + # # Original lines (do not edit): + # # miden-client = ... + # # miden-client-sqlite-store = ... + # $MARK_END + # miden-client = { branch = "", ... } <-- patched + # miden-client-sqlite-store = { branch = "", ... } <-- patched (if present) + # + awk -v b="$MARK_BEGIN" -v e="$MARK_END" ' + function restore() { + for (i=1; i<=n_orig; i++) print orig[i] + # Skip the same number of patched lines that immediately follow. + to_skip = n_orig + } + BEGIN { state="scan"; n_orig=0; to_skip=0 } + state == "scan" && $0 == b { state="capturing"; next } + state == "capturing" && $0 == e { + state="post" + restore() + next + } + state == "capturing" { + # Lines look like "# miden-client = ..." — strip the "# " prefix + # to recover the original line. Comment-only lines (starting with + # "# " followed by something that is not a TOML key=value) are + # discarded. + if (match($0, /^# /)) { + orig[++n_orig] = substr($0, 4) + } + next + } + state == "post" && to_skip > 0 { to_skip--; next } + { print } + ' "$CARGO_TOML" > "$CARGO_TOML.tmp" + mv "$CARGO_TOML.tmp" "$CARGO_TOML" +} + +# cargo update with -p needs the package to actually be in the dep tree. +# main pins only `miden-client` (crates.io); next pins `miden-client` and +# `miden-client-sqlite-store` (git+branch). Build the `-p` list dynamically +# so the same script works on both branches. +build_cargo_update_args() { + local args="" + if cargo metadata --format-version=1 --no-deps 2>/dev/null \ + | grep -qE '"name":"(miden-client|miden-client-web|miden-idxdb-store)"'; then + # Always try miden-client; only add sqlite-store if it's resolvable. + args="-p miden-client" + if cargo pkgid -p miden-client-sqlite-store >/dev/null 2>&1; then + args="$args -p miden-client-sqlite-store" + fi + fi + printf '%s' "$args" +} + +if [ "${1:-}" = "--clear" ] || [ "${1:-}" = "-c" ]; then + clear_block + # shellcheck disable=SC2086 + cargo update $(build_cargo_update_args) --quiet 2>/dev/null || true + echo "✓ Linked-client-pr block removed from Cargo.toml." + exit 0 +fi + +# Determine the linked PR. +arg="${1:-}" +if [ -z "$arg" ]; then + body=$(gh pr view --json body -q .body 2>/dev/null || true) + marker=$(printf '%s' "$body" | grep -ioE '^[[:space:]]*Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || true) + if [ -z "$marker" ]; then + echo "Usage: $0 [ | # | --clear]" + echo + echo "No 'Client PR: #N' marker found in the current branch's PR body." + echo "Add one to the PR description, or pass an arg: $0 1234" + exit 1 + fi + arg="$marker" +fi + +# Parse arg into repo + num. +if printf '%s' "$arg" | grep -qE '^[0-9]+$'; then + repo="0xMiden/miden-client" + num="$arg" +else + repo=$(printf '%s' "$arg" | grep -oE '[0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+' | head -1 || true) + num=$(printf '%s' "$arg" | grep -oE '[0-9]+$') + [ -z "$repo" ] && repo="0xMiden/miden-client" + [ -z "$num" ] && { echo "Could not parse '$arg' — expected '#N' or 'owner/repo#N'."; exit 1; } +fi + +# Resolve head + state via gh. +read -r head_owner head_repo head_ref head_sha state merged <<<"$(gh api repos/"$repo"/pulls/"$num" \ + --jq '"\(.head.repo.owner.login) \(.head.repo.name) \(.head.ref) \(.head.sha) \(.state) \(.merged)"')" + +if [ "$state" != "OPEN" ] && [ "$merged" != "true" ]; then + echo "⚠ ${repo}#${num} is ${state} (merged=${merged}). The branch may be gone." + echo " Continuing anyway — git resolves the head sha if it still exists." +fi + +# Idempotent: clear any prior block first. +clear_block + +url="https://github.com/${head_owner}/${head_repo}.git" + +# Sanity-check there's a miden-client dep line at all. The Python block +# below does the actual capture + rewrite; we exit early here only if +# the file is structurally not what we expect. +if ! grep -qE '^miden-client(-sqlite-store)?[^a-z-]' "$CARGO_TOML"; then + echo "Could not find a miden-client dep line in $CARGO_TOML." >&2 + exit 1 +fi +python3 <miden-client(?:-sqlite-store)?)\s*=\s*(?P.+)$') +captured = [] +patched = [] +out = [] +for ln in lines: + m = dep_re.match(ln) + if not m: + out.append(ln) + continue + name = m.group('name') + rhs = m.group('rhs').rstrip('\n') + captured.append(ln.rstrip('\n')) + # Build a git+branch table that matches the workspace dep style. + # Keep default-features = false if it was on the original. + df = ', default-features = false' if 'default-features = false' in rhs else '' + new_rhs = '{ branch = "' + ref + '"' + df + ', git = "' + url + '" }' + patched.append(f"{name:<25} = {new_rhs}") + +# Inject marker block + patched lines BELOW the first captured-line position. +# Strategy: emit OUT lines until we hit the position where the first dep +# line lived, then drop in the marker + originals (commented) + patched. +out2 = [] +inserted = False +for ln in lines: + if not inserted and dep_re.match(ln): + out2.append(mark_begin + "\n") + out2.append("# Source: ${repo}#${num} (head: ${head_owner}/${head_repo}@${head_ref}, ${head_sha:0:8}).\n") + out2.append("# Original lines (do not edit):\n") + for c in captured: + out2.append("# " + c + "\n") + out2.append(mark_end + "\n") + for p in patched: + out2.append(p + "\n") + inserted = True + # Skip ALL captured dep lines on the way through. + continue + if dep_re.match(ln): + # Skip subsequent captured lines (already replaced above). + continue + out2.append(ln) + +with open(path, 'w') as f: + f.writelines(out2) +PY + +# shellcheck disable=SC2086 +cargo update $(build_cargo_update_args) --quiet + +echo "✓ Cargo.toml dep rewritten: miden-client → ${head_owner}/${head_repo}@${head_ref} (${head_sha:0:8})" +echo " Originals stashed in a marker block; restore with: $0 --clear" +echo " (lefthook's pre-commit hook will block the commit while the marker block is present.)"