From beb46d7cb412f67101b53be75d6dcd0df9909c5c Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 16:31:41 +0200 Subject: [PATCH 1/3] ci: auto-patch miden-client dep from PR description marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a web-sdk PR description contains: Client PR: #1234 Client PR: 0xMiden/miden-client#1234 (cross-repo / fork form) a new composite action (.github/actions/inject-linked-client-pr) parses the marker, resolves the linked PR's head ref, and rewrites web-sdk's miden-client (and miden-client-sqlite-store) dep in place to point at that branch — only on the runner, never committed. The build then runs against the unreleased upstream code, while the committed Cargo.toml diff stays clean. Replaces the manual '[patch]' / branch-edit dance that this session has been carrying on every migration PR. Why in-place rewrite instead of [patch.crates-io] / [patch.""]? Web-sdk's 'next' pins miden-client at git+url=...miden-client.git+ branch=next; pointing the same URL at a different branch via [patch] errors with 'patches must point to different sources'. In-place rewrite covers both 'main' (crates.io dep) and 'next' (git dep) uniformly — the rewritten line is wrapped in marker comments that preserve the original verbatim so a cleanup step can restore it. Components: .github/actions/inject-linked-client-pr/action.yml Composite action: parse marker, resolve head, validate state (closed-without-merge fails the run loudly), patch Cargo.toml + refresh Cargo.lock, post a sticky PR comment summarizing the patch. Strict 0-or-1 comment invariant: only fires when called with comment=true (one designated job per workflow run), and deletes the prior comment if the marker is later removed. .github/workflows/build.yml + test.yml Wired the action into the cargo-compiling jobs (build-wasm, build-web-client-dist-folder, verify-release-build). build-wasm is the single comment-poster (comment=true). .github/workflows/check-linked-client-pr.yml Mergeability gate: keeps a 'linked-client-pr-ready' check on the PR. Stays pending while the linked client PR isn't merged-and- reachable from the target branch's canonical refs (miden-client next for next-targeted PRs, latest miden-client release tag for main-targeted). Re-evaluates every 15 min so the check goes green automatically once upstream catches up — no need to re-push. Configure branch protection to require it. scripts/dev-with-client-pr.sh Local-dev mirror: applies the same in-place dep rewrite to your working tree. Idempotent. '--clear' restores the originals byte-for-byte from the marker block. lefthook.yml Pre-commit guard: refuses any commit while the marker block is present in Cargo.toml. So a forgotten 'apply' can't accidentally leak into a commit. CLAUDE.md Documents the marker convention, the local script, and the mergeability gate. --- .../inject-linked-client-pr/action.yml | 263 ++++++++++++++++++ .github/workflows/build.yml | 9 + .github/workflows/check-linked-client-pr.yml | 168 +++++++++++ .github/workflows/test.yml | 10 + CLAUDE.md | 32 +++ lefthook.yml | 22 ++ scripts/dev-with-client-pr.sh | 207 ++++++++++++++ 7 files changed, 711 insertions(+) create mode 100644 .github/actions/inject-linked-client-pr/action.yml create mode 100644 .github/workflows/check-linked-client-pr.yml create mode 100755 scripts/dev-with-client-pr.sh 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..fc2ba069 --- /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 'Client PR:\s*([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..3534ba4d --- /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 'Client PR:\s*([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..f202edea --- /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 'Client PR:\s*([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.)" From 8103c2b9492e1eaaa85bb32aa5a152192a2384a9 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 16:37:01 +0200 Subject: [PATCH 2/3] fix(ci): use POSIX [[:space:]]* instead of \s in marker grep GNU grep on the runner balked with 'repetition-operator operand invalid' on the \s* in the regex. \s is a Perl-extension class that grep -E doesn't recognize in POSIX-extended mode; the * after it ends up applying to nothing, hence the error. Replace with [[:space:]]* across the composite action, the dev script, and the readiness gate workflow. --- .github/actions/inject-linked-client-pr/action.yml | 2 +- .github/workflows/check-linked-client-pr.yml | 2 +- scripts/dev-with-client-pr.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/inject-linked-client-pr/action.yml b/.github/actions/inject-linked-client-pr/action.yml index fc2ba069..f1386935 100644 --- a/.github/actions/inject-linked-client-pr/action.yml +++ b/.github/actions/inject-linked-client-pr/action.yml @@ -81,7 +81,7 @@ runs: # Marker: "Client PR:" optionally followed by "/" then "#N". # First match wins; case-insensitive. marker=$(printf '%s' "$body" \ - | grep -ioE 'Client PR:\s*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' \ + | grep -ioE '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" diff --git a/.github/workflows/check-linked-client-pr.yml b/.github/workflows/check-linked-client-pr.yml index 3534ba4d..c57c68b5 100644 --- a/.github/workflows/check-linked-client-pr.yml +++ b/.github/workflows/check-linked-client-pr.yml @@ -90,7 +90,7 @@ jobs: fi # Parse marker; absent → check passes (no gate needed). - marker=$(printf '%s' "$body" | grep -ioE 'Client PR:\s*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || true) + marker=$(printf '%s' "$body" | grep -ioE '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) \ diff --git a/scripts/dev-with-client-pr.sh b/scripts/dev-with-client-pr.sh index f202edea..37fcd7bb 100755 --- a/scripts/dev-with-client-pr.sh +++ b/scripts/dev-with-client-pr.sh @@ -100,7 +100,7 @@ fi arg="${1:-}" if [ -z "$arg" ]; then body=$(gh pr view --json body -q .body 2>/dev/null || true) - marker=$(printf '%s' "$body" | grep -ioE 'Client PR:\s*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || true) + marker=$(printf '%s' "$body" | grep -ioE '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 From f1573e00bfb6fee761fd040c6778917cf2204a5e Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Thu, 30 Apr 2026 16:41:17 +0200 Subject: [PATCH 3/3] fix(ci): anchor Client PR marker regex to start-of-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the ^[[:space:]]* prefix, doc examples or table cells in the PR body that mention 'Client PR:' inline (e.g. inside backticks for a test-plan checklist) are picked up as if they were real markers — the action then tries to fetch a fake miden-client PR and fails with 'Failed to fetch'. Anchoring requires the marker to start its own line, so inline mentions in surrounding prose stay decorative. Doc examples that use placeholders like # instead of #N (a literal number) are also rejected by the existing #[0-9]+ requirement. Pairs with the PR-body cleanup that replaces the literal example 'Client PR: #1234' with 'Client PR: #' so it's clearly a template. --- .github/actions/inject-linked-client-pr/action.yml | 2 +- .github/workflows/check-linked-client-pr.yml | 2 +- scripts/dev-with-client-pr.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/inject-linked-client-pr/action.yml b/.github/actions/inject-linked-client-pr/action.yml index f1386935..2464b4bf 100644 --- a/.github/actions/inject-linked-client-pr/action.yml +++ b/.github/actions/inject-linked-client-pr/action.yml @@ -81,7 +81,7 @@ runs: # Marker: "Client PR:" optionally followed by "/" then "#N". # First match wins; case-insensitive. marker=$(printf '%s' "$body" \ - | grep -ioE 'Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' \ + | 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" diff --git a/.github/workflows/check-linked-client-pr.yml b/.github/workflows/check-linked-client-pr.yml index c57c68b5..5aef97e2 100644 --- a/.github/workflows/check-linked-client-pr.yml +++ b/.github/workflows/check-linked-client-pr.yml @@ -90,7 +90,7 @@ jobs: fi # Parse marker; absent → check passes (no gate needed). - marker=$(printf '%s' "$body" | grep -ioE 'Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || 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 "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) \ diff --git a/scripts/dev-with-client-pr.sh b/scripts/dev-with-client-pr.sh index 37fcd7bb..5b194b70 100755 --- a/scripts/dev-with-client-pr.sh +++ b/scripts/dev-with-client-pr.sh @@ -100,7 +100,7 @@ fi arg="${1:-}" if [ -z "$arg" ]; then body=$(gh pr view --json body -q .body 2>/dev/null || true) - marker=$(printf '%s' "$body" | grep -ioE 'Client PR:[[:space:]]*([0-9a-zA-Z._-]+/[0-9a-zA-Z._-]+)?#[0-9]+' | head -1 || 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