From 0573c4d8b4df4822d3a43bdc930685825e3aabbf Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Sun, 16 Aug 2026 03:06:39 -0700 Subject: [PATCH 1/5] ci: open the sync PR to main automatically after a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release commit lands on the release branch only. Until it reaches main, main's newest reachable tag is the previous release and the next release computes a version that already exists — this happened for 2.4.1 and 2.4.2, both of which needed a manual sync PR. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2cc2595..79a30bf 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,7 @@ jobs: permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@v4 @@ -48,3 +49,32 @@ jobs: run: npx release-it ${{ inputs.increment != 'auto' && inputs.increment || '' }} --ci ${{ inputs.dry_run && '--dry-run' || '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The release commit (version bump + changelog) is made on the release + # branch. Until it reaches main, main's newest reachable tag is the + # previous release, and the next release computes a version that already + # exists. + - name: Open sync PR to main + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + BRANCH="${{ github.ref_name }}" + VERSION=$(node -p "require('./package.json').version") + + EXISTING=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Sync PR already open for $BRANCH: #$EXISTING" + exit 0 + fi + + gh pr create --base main --head "$BRANCH" \ + --title "chore: sync release $VERSION back to main" \ + --body "$(printf '%s\n' \ + "Merges the \`chore: release v$VERSION\` commit (version bump + changelog) back into \`main\`." \ + "" \ + "Opened automatically by the Create Release workflow. Without it, \`main\` keeps the previous version and the next release branch computes a version that already exists." \ + "" \ + "No code changes - release bookkeeping only.")" From ddc4060fe44e98ec47ad44fc6245f0cb8691e15b Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Sun, 16 Aug 2026 10:58:12 -0700 Subject: [PATCH 2/5] ci: harden the sync PR step - read the dispatch ref from env and require release/, so a branch name cannot inject shell into the step - serialize runs per ref: the list-then-create pair is not atomic and two runs could open duplicate PRs - run the step after a failed release too. release-it pushes the commit and tag before creating the GitHub release, so a late failure would otherwise strand the tag off main. A HEAD subject check keeps it from opening a PR when no release commit was made. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yaml | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 79a30bf..a849fca 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,6 +20,13 @@ on: jobs: release: runs-on: ubuntu-latest + + # The sync PR is opened with a list-then-create pair, which is not atomic. + # Serializing runs per ref keeps two of them from opening duplicates. + concurrency: + group: release-sync-${{ github.ref }} + cancel-in-progress: false + defaults: run: working-directory: ./mcp-server @@ -55,22 +62,38 @@ jobs: # previous release, and the next release computes a version that already # exists. - name: Open sync PR to main - if: ${{ !inputs.dry_run }} + # Also runs when the release step failed: release-it pushes the commit + # and tag before it creates the GitHub release, so a late failure still + # leaves a release commit that main needs. + if: ${{ !cancelled() && !inputs.dry_run }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail - BRANCH="${{ github.ref_name }}" + # Read the ref as data, never as shell source: a branch name is + # attacker-controlled by anyone with write access. + if ! printf '%s' "$RELEASE_BRANCH" | grep -Eq '^release/[A-Za-z0-9._-]+$'; then + echo "Not a release branch, skipping sync PR: $RELEASE_BRANCH" + exit 0 + fi + VERSION=$(node -p "require('./package.json').version") - EXISTING=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number // empty') + # Nothing to sync unless release-it actually made the release commit. + if [ "$(git log -1 --pretty=%s)" != "chore: release v$VERSION" ]; then + echo "No release commit on HEAD, skipping sync PR." + exit 0 + fi + + EXISTING=$(gh pr list --head "$RELEASE_BRANCH" --base main --state open --json number --jq '.[0].number // empty') if [ -n "$EXISTING" ]; then - echo "Sync PR already open for $BRANCH: #$EXISTING" + echo "Sync PR already open for $RELEASE_BRANCH: #$EXISTING" exit 0 fi - gh pr create --base main --head "$BRANCH" \ + gh pr create --base main --head "$RELEASE_BRANCH" \ --title "chore: sync release $VERSION back to main" \ --body "$(printf '%s\n' \ "Merges the \`chore: release v$VERSION\` commit (version bump + changelog) back into \`main\`." \ From 83e1512ca0ea34f9e52bbf34a68a5c20b5c07b37 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Sun, 16 Aug 2026 11:03:59 -0700 Subject: [PATCH 3/5] ci: require a branch dispatch and a pushed release commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag named release/x.y.z passed the branch allowlist, and release-it commits before it pushes — so a failed push left the release commit only on the runner while the step still opened a PR without it. Both matter more now that the step also runs after a failed release. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a849fca..9f58e6e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -69,9 +69,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BRANCH: ${{ github.ref_name }} + RELEASE_REF_TYPE: ${{ github.ref_type }} run: | set -euo pipefail + # A tag can be named release/x.y.z and would pass the branch check. + if [ "$RELEASE_REF_TYPE" != "branch" ]; then + echo "Dispatched from a $RELEASE_REF_TYPE, skipping sync PR." + exit 0 + fi + # Read the ref as data, never as shell source: a branch name is # attacker-controlled by anyone with write access. if ! printf '%s' "$RELEASE_BRANCH" | grep -Eq '^release/[A-Za-z0-9._-]+$'; then @@ -87,6 +94,14 @@ jobs: exit 0 fi + # release-it commits before it pushes. If the push failed, the commit + # exists only on the runner and a PR would not contain it. + REMOTE_HEAD=$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1 || true) + if [ "$REMOTE_HEAD" != "$(git rev-parse HEAD)" ]; then + echo "Release commit is not on origin/$RELEASE_BRANCH, skipping sync PR." + exit 0 + fi + EXISTING=$(gh pr list --head "$RELEASE_BRANCH" --base main --state open --json number --jq '.[0].number // empty') if [ -n "$EXISTING" ]; then echo "Sync PR already open for $RELEASE_BRANCH: #$EXISTING" From 9aee0a4080982f9ae48032a959feac0758a4c5fa Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Sun, 16 Aug 2026 11:16:44 -0700 Subject: [PATCH 4/5] ci: decide the sync PR from the remote release branch The runner checkout is the dispatch sha, so a re-run sees a pre-release HEAD and skipped the PR the re-run was meant to open. Read the branch tip, the version, and the tag from origin instead, and test the tag by ancestry so a branch that moved on still syncs. Also allow `+` in the branch allowlist, which a semver build version needs. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yaml | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9f58e6e..ee53fd6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -81,24 +81,30 @@ jobs: # Read the ref as data, never as shell source: a branch name is # attacker-controlled by anyone with write access. - if ! printf '%s' "$RELEASE_BRANCH" | grep -Eq '^release/[A-Za-z0-9._-]+$'; then + # `+` is legal in a semver build version and release-it accepts the + # branch, so the allowlist has to allow it too. + if ! printf '%s' "$RELEASE_BRANCH" | grep -Eq '^release/[A-Za-z0-9.+_-]+$'; then echo "Not a release branch, skipping sync PR: $RELEASE_BRANCH" exit 0 fi - VERSION=$(node -p "require('./package.json').version") - - # Nothing to sync unless release-it actually made the release commit. - if [ "$(git log -1 --pretty=%s)" != "chore: release v$VERSION" ]; then - echo "No release commit on HEAD, skipping sync PR." + # Decide from the remote branch rather than the runner's checkout: a + # re-run of this workflow checks out the dispatch sha, which predates + # the release commit, and the PR is opened from the remote branch + # anyway. + REMOTE_TIP=$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1 || true) + if [ -z "$REMOTE_TIP" ]; then + echo "No origin/$RELEASE_BRANCH, skipping sync PR." exit 0 fi - # release-it commits before it pushes. If the push failed, the commit - # exists only on the runner and a PR would not contain it. - REMOTE_HEAD=$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1 || true) - if [ "$REMOTE_HEAD" != "$(git rev-parse HEAD)" ]; then - echo "Release commit is not on origin/$RELEASE_BRANCH, skipping sync PR." + git fetch --quiet --tags origin "$RELEASE_BRANCH" + VERSION=$(git show "$REMOTE_TIP:mcp-server/package.json" | jq -r .version) + + # The release is only real once its tag is pushed and contained in + # the branch. Ancestry, not equality: the branch may have moved on. + if ! git merge-base --is-ancestor "refs/tags/v$VERSION" "$REMOTE_TIP" 2>/dev/null; then + echo "v$VERSION is not on origin/$RELEASE_BRANCH, skipping sync PR." exit 0 fi From be9f2b920fcffe53582442bc4745207241a3d89c Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Sun, 16 Aug 2026 11:26:51 -0700 Subject: [PATCH 5/5] ci: isolate pull-requests write in its own job release-it runs the repository's test suite through its before:init hook, so keeping pull-requests: write on that job handed repository code a token that can open and edit PRs. The sync PR now runs in a separate job that executes no repository code, and the release job goes back to contents: write. Also reject a version that is not semver before it reaches the tag ref and the PR body. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yaml | 54 ++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ee53fd6..d245a63 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,19 +21,12 @@ jobs: release: runs-on: ubuntu-latest - # The sync PR is opened with a list-then-create pair, which is not atomic. - # Serializing runs per ref keeps two of them from opening duplicates. - concurrency: - group: release-sync-${{ github.ref }} - cancel-in-progress: false - defaults: run: working-directory: ./mcp-server permissions: contents: write - pull-requests: write steps: - uses: actions/checkout@v4 @@ -57,15 +50,37 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # The release commit (version bump + changelog) is made on the release - # branch. Until it reaches main, main's newest reachable tag is the - # previous release, and the next release computes a version that already - # exists. + # The release commit (version bump + changelog) is made on the release + # branch. Until it reaches main, main's newest reachable tag is the previous + # release, and the next release computes a version that already exists. + # + # Separate job on purpose: `pull-requests: write` must not be in scope while + # release-it runs the repository's own test suite through its before:init + # hook. Nothing here executes repository code. + sync-pr: + needs: release + # Also runs when the release job failed: release-it pushes the commit and + # tag before it creates the GitHub release, so a late failure still leaves + # a release commit that main needs. + if: ${{ !cancelled() && !inputs.dry_run }} + runs-on: ubuntu-latest + + # The sync PR is opened with a list-then-create pair, which is not atomic. + # Serializing runs per ref keeps two of them from opening duplicates. + concurrency: + group: release-sync-${{ github.ref }} + cancel-in-progress: false + + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Open sync PR to main - # Also runs when the release step failed: release-it pushes the commit - # and tag before it creates the GitHub release, so a late failure still - # leaves a release commit that main needs. - if: ${{ !cancelled() && !inputs.dry_run }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BRANCH: ${{ github.ref_name }} @@ -99,7 +114,14 @@ jobs: fi git fetch --quiet --tags origin "$RELEASE_BRANCH" - VERSION=$(git show "$REMOTE_TIP:mcp-server/package.json" | jq -r .version) + VERSION=$(git show "$REMOTE_TIP:mcp-server/package.json" | jq -r '.version // ""') + + # jq yields "null" for a missing or non-string version, which would + # otherwise reach the tag ref and the PR body. + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)*$'; then + echo "Unexpected version at the branch tip, skipping sync PR." + exit 0 + fi # The release is only real once its tag is pushed and contained in # the branch. Ancestry, not equality: the branch may have moved on.