Skip to content
Closed
130 changes: 64 additions & 66 deletions .github/workflows/sync-patches.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
name: 🔄 Sync stable → main

# Stable pushes first run semantic-release, which can append version/tag
# commits back to stable. Sync only after that release lane finishes so main
# never gets a PR from an intermediate stable head.
# Stable releases can append version and tag commits. Wait for that lane to
# drain, then pin its settled head before merging it into main.
on:
workflow_run:
workflows:
- "📦 Release stable tooling (CLI/SDK/MCP)"
- '📦 Release stable tooling (CLI/SDK/MCP)'
types:
- completed
workflow_dispatch:

permissions:
actions: read
contents: write
pull-requests: write

concurrency:
group: sync-stable-to-main
Expand All @@ -27,15 +25,13 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(
github.event.workflow_run.head_branch == 'stable' &&
(
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
)
github.event.workflow_run.conclusion == 'success'
)

steps:
# Manual dispatch is the recovery path after an investigated failure,
# but it still waits for active stable releases before pinning stable.
- name: Wait for stable release lane to drain
if: github.event_name == 'workflow_run'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
Expand All @@ -52,7 +48,7 @@ jobs:
--jq '[.[] | select((.name == "📦 Release stable tooling (CLI/SDK/MCP)" or .name == "📦 Release esign" or .name == "📦 Release template-builder") and .status != "completed")] | length')

if [ "$active_runs" -eq 0 ]; then
echo "Stable release lane is idle."
echo "Stable release lane is drained."
break
fi

Expand Down Expand Up @@ -85,26 +81,68 @@ jobs:

- name: Sync stable into main
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
EVENT_NAME: ${{ github.event_name }}
TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

git fetch origin main stable --tags --prune
stable_sha=$(git rev-parse origin/stable)

SYNC_BRANCH="sync/stable-to-main-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$SYNC_BRANCH" origin/main
if [ "$EVENT_NAME" = "workflow_run" ]; then
if ! git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"; then
echo "Pinned stable SHA does not descend from the successful tooling release."
exit 1
fi

release_subject_pattern='^chore(\([^)]+\))?: [^[:space:]]+ \[skip ci\]$'
unexpected_commit=false
while read -r sha subject; do
[ -z "$sha" ] && continue

author_email=$(git show -s --format='%ae' "$sha")
committer_email=$(git show -s --format='%ce' "$sha")
if [ "$author_email" != "semantic-release-bot@martynus.net" ] ||
[ "$committer_email" != "semantic-release-bot@martynus.net" ] ||
[[ ! "$subject" =~ $release_subject_pattern ]]; then
echo "Pinned stable includes a non-release commit: $sha $subject"
unexpected_commit=true
continue
fi

changed_files=$(git diff-tree --no-commit-id --name-only -r "$sha")
if [ -z "$changed_files" ]; then
echo "Release writeback has no version files: $sha"
unexpected_commit=true
continue
fi

while read -r f; do
case "$f" in
package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) ;;
*)
echo "Release writeback changes an unexpected file: $sha $f"
unexpected_commit=true
;;
esac
done <<< "$changed_files"
done < <(git log --format='%H %s' "$TRIGGER_STABLE_SHA..$stable_sha")
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

if [ "$unexpected_commit" = true ]; then
exit 1
fi
fi

if git merge-base --is-ancestor origin/stable origin/main; then
git checkout --detach origin/main

if git merge-base --is-ancestor "$stable_sha" origin/main; then
echo "No changes to sync - stable is already in main's ancestry."
exit 0
fi

PR_TITLE="🔄 Sync stable → main"
MERGE_STATUS="clean"

normalize_json_without_keys() {
python3 -c 'import json, sys; data = json.load(sys.stdin); [data.pop(key, None) for key in sys.argv[1:]]; sys.stdout.write(json.dumps(data, sort_keys=True, separators=(",", ":")))' "$@"
}
Expand Down Expand Up @@ -140,17 +178,13 @@ jobs:
return "${status}"
}

# Use a real merge, not a squash, so stable release tags become
# reachable from main. semantic-release uses reachable tags as the
# version floor for @next prereleases.
if git merge --no-ff --no-edit origin/stable; then
MERGE_STATUS="clean"
else
# A real merge makes stable release tags reachable from main, which
# semantic-release needs as the version floor for @next.
if ! git merge --no-ff --no-edit "$stable_sha"; then
echo "Merge conflict — attempting auto-resolution of release artifacts..."

# Auto-resolve release artifact conflicts only when the conflict is
# version-only: keep stable's already-published version, but never
# drop dependency, script, export, or package metadata changes.
# Only published-version fields may take stable's side. Other
# package metadata conflicts remain manual.
while read -r f; do
case "$f" in
package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json)
Expand All @@ -166,7 +200,6 @@ jobs:
done < <(git diff --name-only --diff-filter=U)

if [ -z "$(git diff --name-only --diff-filter=U)" ]; then
MERGE_STATUS="auto_resolved"
git commit -m "chore: merge stable into main (release conflicts auto-resolved)"
else
echo "Unresolved conflicts remain after version-only release artifact auto-resolution."
Expand All @@ -176,45 +209,10 @@ jobs:
fi
fi

if ! git merge-base --is-ancestor origin/stable HEAD; then
if ! git merge-base --is-ancestor "$stable_sha" HEAD; then
echo "Stable is still not in the sync branch ancestry after merge."
exit 1
fi

git push origin "$SYNC_BRANCH"

# Check for existing open sync PR
EXISTING=$(gh pr list --base main --head "$SYNC_BRANCH" --state open --json number -q '.[0].number' 2>/dev/null || true)
if [ -n "$EXISTING" ]; then
echo "Sync PR #$EXISTING already exists."
exit 0
fi

gh pr create \
--base main \
--head "$SYNC_BRANCH" \
--title "$PR_TITLE" \
--body "$(cat <<EOF
## Summary

Merges latest stable patches into main with a real merge commit so stable release tags remain reachable from main.

This keeps \`@next\` prerelease version calculation ahead of the latest published stable release.

## Merge requirement

This PR must be merged with GitHub's merge-commit option. Squash or rebase merging will discard stable ancestry and break \`@next\` version calculation again.

## Conflict handling

Merge status: \`${MERGE_STATUS}\`.

Version-only release artifact conflicts are auto-resolved to keep stable's already-published versions. Non-version conflicts fail this workflow instead of committing conflict markers.

---
_Auto-created by sync-patches workflow._
EOF
)" \
--label "patch-sync"

echo "✅ Created sync PR"
# Push this merge unchanged. A normal push fails safely if main moved.
git push origin HEAD:main
76 changes: 63 additions & 13 deletions scripts/__tests__/release-local.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -388,15 +388,23 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag
}
});

test('stable-to-main sync waits for stable release completion', async () => {
test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => {
const workflow = await readRepoFile('.github/workflows/sync-patches.yml');
const laneDrainStart = workflow.indexOf('- name: Wait for stable release lane to drain');
const laneDrainEnd = workflow.indexOf('- name: Generate token');

assert.notEqual(laneDrainStart, -1, '.github/workflows/sync-patches.yml: must include the stable lane drain step');
assert.notEqual(laneDrainEnd, -1, '.github/workflows/sync-patches.yml: must include the token generation step');
assert.ok(laneDrainStart < laneDrainEnd, '.github/workflows/sync-patches.yml: must drain the stable lane before checkout');

const laneDrainStep = workflow.slice(laneDrainStart, laneDrainEnd);

assert.ok(
workflow.includes('workflow_run:'),
'.github/workflows/sync-patches.yml: must trigger from release workflow completion, not directly from stable pushes',
);
assert.ok(
/workflows:\s*\n\s*-\s*"📦 Release stable tooling \(CLI\/SDK\/MCP\)"/.test(workflow),
/workflows:\s*\n\s*-\s*['"]📦 Release stable tooling \(CLI\/SDK\/MCP\)['"]/.test(workflow),
'.github/workflows/sync-patches.yml: must trigger after the stable release orchestrator completes',
);
assert.equal(
Expand All @@ -409,9 +417,17 @@ test('stable-to-main sync waits for stable release completion', async () => {
'.github/workflows/sync-patches.yml: must scope automatic syncs to stable release runs',
);
assert.ok(
workflow.includes("github.event.workflow_run.conclusion == 'success'") &&
workflow.includes("github.event.workflow_run.conclusion == 'failure'"),
'.github/workflows/sync-patches.yml: must wait for release completion while still surfacing failed-release sync PRs for review',
workflow.includes("github.event.workflow_run.conclusion == 'success'"),
'.github/workflows/sync-patches.yml: automatic syncs must require a successful stable tooling release',
);
assert.equal(
workflow.includes("github.event.workflow_run.conclusion == 'failure'"),
false,
'.github/workflows/sync-patches.yml: failed stable tooling releases must not push to main automatically',
);
assert.ok(
workflow.includes("github.event_name == 'workflow_dispatch'"),
'.github/workflows/sync-patches.yml: manual dispatch must remain available for investigated recovery',
);
assert.ok(
workflow.includes('actions: read'),
Expand All @@ -423,24 +439,58 @@ test('stable-to-main sync waits for stable release completion', async () => {
workflow.includes('"📦 Release template-builder"'),
'.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable',
);
assert.equal(
laneDrainStep.includes("if: github.event_name == 'workflow_run'"),
false,
'.github/workflows/sync-patches.yml: manual recovery must also wait for active stable release runs to finish',
);
for (const removedGate of [
'Verify stable release lane succeeded',
'id: verify_release_lane',
'require_successful_release()',
'steps.verify_release_lane.outputs.stable_sha',
]) {
assert.equal(
workflow.includes(removedGate),
false,
`.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`,
);
}
});

test('stable-to-main sync preserves stable release ancestry', async () => {
const workflow = await readRepoFile('.github/workflows/sync-patches.yml');
const stableShaAssignment = 'stable_sha=$(git rev-parse origin/stable)';
const pinIndex = workflow.indexOf(stableShaAssignment);
const verificationIndex = workflow.indexOf('git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"');
const mergeIndex = workflow.indexOf('git merge --no-ff --no-edit "$stable_sha"');

assert.equal(
workflow.includes('git merge --squash'),
false,
'.github/workflows/sync-patches.yml: stable-to-main sync must not squash because semantic-release needs stable tags reachable from main',
);
assert.ok(
workflow.includes('git merge --no-ff --no-edit origin/stable'),
'.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit',
mergeIndex !== -1,
'.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the pinned stable SHA',
);
assert.ok(
workflow.includes('git merge-base --is-ancestor origin/stable origin/main') &&
workflow.includes('git merge-base --is-ancestor origin/stable HEAD'),
'.github/workflows/sync-patches.yml: sync must guard on and verify stable ancestry',
pinIndex !== -1 &&
(workflow.match(/stable_sha=\$\(git rev-parse origin\/stable\)/g) ?? []).length === 1 &&
workflow.indexOf('git fetch origin main stable --tags --prune') < pinIndex &&
pinIndex < mergeIndex &&
!workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('git fetch origin') &&
!workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('origin/stable') &&
pinIndex < verificationIndex &&
verificationIndex < mergeIndex &&
workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') &&
workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') &&
workflow.includes('semantic-release-bot@martynus.net') &&
workflow.includes('release_subject_pattern=') &&
workflow.includes('git diff-tree --no-commit-id --name-only -r "$sha"') &&
workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') &&
workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'),
'.github/workflows/sync-patches.yml: must verify release writeback provenance and files before merging the pinned stable SHA',
);
assert.ok(
workflow.includes('release_artifact_only_conflict') &&
Expand All @@ -451,11 +501,11 @@ test('stable-to-main sync preserves stable release ancestry', async () => {
assert.equal(
workflow.includes('git add -A'),
false,
'.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers into review PRs',
'.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers',
);
assert.ok(
workflow.includes("This PR must be merged with GitHub's merge-commit option"),
'.github/workflows/sync-patches.yml: generated PRs must warn reviewers not to squash away stable ancestry',
workflow.includes('git push origin HEAD:main'),
'.github/workflows/sync-patches.yml: sync must push the verified merge commit directly so stable ancestry is preserved',
);
});

Expand Down
Loading