# Release Sync

> Sync main back to dev after a squash-merge release — files a PR that makes the release squash commit an ancestor of dev, eliminating future merge conflict accumulation.

- Skill: `me2resh/release-sync` (Agent Skill)
- Install (CLI): `npx skillmds@latest add me2resh/release-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/me2resh/release-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: me2resh (https://skillmd.com/u/me2resh)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/me2resh/release-sync

---


# /release-sync — Sync main→dev after a release

The sync PR body is a durable, human-facing artefact. Read
`.claude/rules/writing-standard.md`. Use the **controlled technical writing profile**: lead with the
outcome and next action, use plain concise prose, and remove empty sections.

Every squash-merge release (`dev → main`) creates a SHA divergence: the squash commit on `main` is absent from `dev`, so `dev` still carries the un-squashed equivalents as separate commits. Repeated releases accumulate the divergence until the next `dev → main` release PR becomes a conflict-heavy nightmare (v2.0.0 suffered 99 conflicts because of this). This skill closes the loop: after each release, file a `main→dev` sync PR that makes the squash commit an ancestor of `dev`, so future release PRs only see genuinely-new commits.

This skill is **framework-only** — only for the `me2resh/apexyard` framework repo. It has no meaning on managed projects, which are trunk-based and never squash-merge to a separate `main`.

## Usage

```
/release-sync v2.0.3
```

Typically invoked as the final step of `/release`, after the release tag has been pushed.

## Process

### 1. Pre-flight

Verify:

- Current repo IS the apexyard framework (origin or upstream points at `me2resh/apexyard`). Refuse otherwise.
- `<version>` argument provided and matches `v\d+\.\d+\.\d+`. Refuse if missing or malformed.
- `upstream/main` and `upstream/dev` exist (`git rev-parse --verify`). Refuse if either is absent.
- The tag `<version>` exists on `upstream/main` (`git tag -l <version>`). Warn if absent (the release may not have completed yet).

### 2. Check for divergence

```bash
git fetch upstream main dev --tags
COMMITS_ON_MAIN_NOT_ON_DEV=$(git log upstream/dev..upstream/main --oneline | wc -l | tr -d ' ')
```

- If `COMMITS_ON_MAIN_NOT_ON_DEV -eq 0`: **already in sync** — print a single-line message and exit 0 (no-op). Do NOT open a PR.
- If only `upstream/dev..upstream/main` is empty but `upstream/main..upstream/dev` is also empty: branches are identical — exit 0.
- If `COMMITS_ON_MAIN_NOT_ON_DEV -gt 0`: proceed with the sync.

### 3. Check for backwards case

```bash
COMMITS_ON_DEV_NOT_ON_MAIN=$(git log upstream/main..upstream/dev --oneline | wc -l | tr -d ' ')
```

This check is informational only — having dev ahead of main is the expected normal state (dev has new work not yet released). Proceed normally.

However, if `COMMITS_ON_MAIN_NOT_ON_DEV -eq 0` AND `COMMITS_ON_DEV_NOT_ON_MAIN -gt 0`: branches are divergence-free from the main→dev direction (main has nothing dev doesn't). Exit 0, already in sync.

### 4. Create the sync branch

```bash
git checkout -b sync/main-to-dev-after-<version> upstream/dev
```

The branch is based on `upstream/dev` (NOT `upstream/main`). This is intentional — we're merging main INTO dev, not branching from main.

> **`sync` is a whitelisted type** (apexyard#458). The `sync/` branch prefix, the `sync:` commit subject, and the `sync(#N):` PR title are all accepted by the branch / commit / PR-title validators. The branch name `sync/main-to-dev-after-vN.N.N` is also exempt from the `{type}/{TICKET-ID}-{desc}` ticket-id requirement (same narrow exception as `release/vN.N.N`) — the release being synced is the ticket. The PR title still references a live OPEN ticket via `sync(#N):` (use the release-cut ticket or a dedicated sync ticket), since `validate-pr-create.sh` checks ticket existence independent of the type.

### 5. Merge main with `-X ours`

```bash
git merge --no-ff -X ours -m "sync: merge main into dev after <version> release

Squash-merge divergence from the <version> release PR creates phantom divergence
between main and dev. This merge makes the <version> squash commit an ancestor
of dev so future dev→main release PRs only see genuinely-new commits.

Strategy: -X ours (dev wins on conflicts) — correct because dev already has the
un-squashed equivalents of everything in the squash commit.

Refs #403" upstream/main
```

**Why `-X ours` and not `-X theirs`?**

We are ON a branch rooted in `dev`. When we run `git merge upstream/main`:

- "ours" = the current branch (dev-based) — this is what we want to win
- "theirs" = the incoming side (main's squash commit)


Dev already has the un-squashed versions of all content in the squash commit. Any conflict means dev's version is the correct authoritative one. `-X ours` preserves dev's content everywhere there's a conflict, which is semantically correct.

**Important:** `-X ours` resolves conflicts automatically. It does NOT mean we wholesale replace main's content. Git will only apply this strategy to the conflict regions, not to content that differs cleanly. The merge will preserve any genuine new content introduced in the release commit that wasn't already in dev.

### 5b. Carry forward `CHANGELOG.md` from main (apexyard#448)

The `-X ours` strategy is correct for *code* — dev already has the un-squashed equivalents and should win on every conflict. But `CHANGELOG.md` is the one file where the opposite is true: every release writes new entries on `main`, and `dev` should track those entries forward. Without this step the release-notes history accumulates only on `main`, and the next `/release` run prepends a new entry on a stale `dev` CHANGELOG and the squash-merge silently truncates the prior releases on `main` (see apexyard#446 / #447 for the symptom this caused for v2.2.0).

After the `-X ours` merge above, **check whether `CHANGELOG.md` on the sync branch differs from `upstream/main`'s copy. If yes, replace it with main's copy and commit it as a separate atomic commit on top of the merge.**

```bash
# Compare the sync branch's CHANGELOG to main's. Use --quiet so the exit
# code is the load-bearing signal: 0 = same, 1 = different.
if ! git diff --quiet upstream/main -- CHANGELOG.md; then
  echo "Carrying forward CHANGELOG.md from main..."
  git checkout upstream/main -- CHANGELOG.md
  # Re-check: did the checkout actually change anything in the working tree?
  if ! git diff --quiet --cached -- CHANGELOG.md \
      || ! git diff --quiet -- CHANGELOG.md; then
    git add CHANGELOG.md
    git commit -m "sync: carry forward CHANGELOG.md from main after <version> release

The -X ours merge above kept dev's CHANGELOG.md, which lacks the entries
written on main during the <version> release flow. This commit restores
main's CHANGELOG so dev tracks the full release history forward.

Without this step the next /release run would prepend the new version
entry on a stale dev CHANGELOG, and the squash-merge to main would silently
truncate the prior releases — the exact pre-v2.2.0 regression captured in
apexyard#446 and root-caused in apexyard#448.

Refs #448"
  fi
fi
```

**Path-specific by design (v1).** This step is hardcoded to `CHANGELOG.md` — the one file the release flow writes on `main`. Generalising to other "main-leads" files is deferred until a second one shows up (and would warrant the YAML config knob mentioned in #448 § "Design Notes").

**Why a separate commit rather than amending the merge.** The carry-forward is a deliberate, audit-trail-visible step. Leaving it as its own commit makes the operation reviewable in the sync PR (Rex sees two commits and can sanity-check each); amending would hide the carry-forward inside the merge commit and obscure the audit trail.

**Idempotent.** Re-running `/release-sync` on an already-synced repo finds `git diff --quiet upstream/main -- CHANGELOG.md` returns 0, the `if` block is skipped, and no commit is created. The existing "already in sync" guard in step 2 still catches the all-empty case; this guard handles the narrower "code is synced but CHANGELOG drifted in a prior unfixed run" case.

**What this step does NOT do.**

- Does **not** touch any file other than `CHANGELOG.md`.
- Does **not** modify `main`'s tree — only updates the sync branch's `CHANGELOG.md` to match.
- Does **not** rewrite history — the carry-forward is a fresh commit on top of the merge commit.
- Does **not** run if `main`'s `CHANGELOG.md` equals dev's (post-merge) — the `if` guard skips the entire block.
- Does **not** preserve in-flight `CHANGELOG.md` edits on `dev`. Under the release-cut model `dev` does NOT add CHANGELOG entries between releases — only `/release` writes there — so this is the expected steady-state. If an adopter has hand-edited `CHANGELOG.md` on `dev`, the carry-forward overwrites those edits. The right shape for that case is to land the edits via the `/release` skill (or a chore PR) before invoking `/release-sync`.

### 6. Push and open the PR

```bash
git push upstream sync/main-to-dev-after-<version>
gh pr create \
  --repo me2resh/apexyard \
  --base dev \
  --head sync/main-to-dev-after-<version> \
  --title "sync(#403): main→dev after <version> release" \
  --body "<PR body — see template below>"
```

**PR body template:**

```markdown
## Summary

- **Syncs main→dev after the <version> release** — makes the <version> squash commit
  an ancestor of `dev` so the next `dev→main` release PR only sees genuinely-new
  commits instead of fighting the accumulated squash divergence
- **Merge strategy: `-X ours`** — dev wins on every conflict because dev already
  carries the un-squashed equivalents of all content in the squash commit; the
  strategy is semantically safe and correct in this direction
- **`CHANGELOG.md` is carried forward separately** — `-X ours` would drop the release-notes
  entries written on main, so a second commit on top of the merge restores `main`'s
  `CHANGELOG.md` verbatim. Path-specific, audit-trail-visible, idempotent. See apexyard#448.
- **No functional changes** — this is a bookkeeping merge that reconciles SHA
  divergence introduced by the squash-merge release flow; no logic is added or removed

## Background

The apexyard release flow squash-merges dev→main on every release. This creates a
divergence: main has one squash commit (SHA X); dev still has the original un-squashed
commits. A future dev→main release PR then conflicts on all the diffs that X also
touched. v2.0.0 suffered 99 conflicts because of this accumulated gap.

This PR is the low-ceremony fix: merge main→dev with `-X ours` so the squash commit
becomes an ancestor of dev. Future release PRs then only show genuinely-new commits
in the diff.

See [#403](https://github.com/me2resh/apexyard/issues/403) for full root-cause analysis.

## Testing

1. After merging, verify: `git log upstream/dev..upstream/main --oneline` returns empty
2. Verify ancestry, not `main..dev`'s size: `git merge-base --is-ancestor <release-squash-sha> upstream/dev` exits 0. (#1002 — `git log upstream/main..upstream/dev --oneline` does **not** shrink to "only commits newer than \<version\>"; under the release-cut squash model no individual `dev` commit is ever reachable from `main`, so that range accumulates every past release's un-squashed history and stays large — currently ~400 commits — even on a perfectly-synced repo. The merge-base check is the assertion that actually proves the sync worked.)
3. Verify CHANGELOG is in sync: `diff <(git show upstream/main:CHANGELOG.md) <(git show upstream/dev:CHANGELOG.md)` returns empty (apexyard#448)
4. Open a test release PR from dev → main — confirm only new work appears in the diff and the next `/release` v<next-version> prepends cleanly on top of <version>

Refs #403, #448, #1002

---

## Glossary

| Term | Definition |
|------|------------|
| Squash divergence | When a release PR is squash-merged to main, the resulting commit has a different SHA than the equivalent dev history, so dev still carries the un-squashed commits as "unsynced" |
| `-X ours` | Git merge strategy option that resolves conflicts in favour of "our" side — when on a dev-based branch merging main, "ours" = dev, which is correct because dev already has the un-squashed equivalents |
| `sync/main-to-dev-after-<version>` | Short-lived branch used to carry the merge commit from main into dev; deleted after the PR merges |
| CHANGELOG carry-forward | Path-specific step 5b that restores `main`'s `CHANGELOG.md` on the sync branch after the `-X ours` merge would otherwise drop the release-notes entries written on `main`. Atomic separate commit, idempotent re-run. See apexyard#448. |
```

### 7. Stop at PR creation

Do **NOT** merge the sync PR. Rex + CEO approval applies to this PR the same as any other. The skill's job is to open the PR; the operator drives the merge gate.

**CRITICAL — merge strategy for this PR:**

> The sync PR **MUST** be merged with a **true merge (`--merge`)**, never `--squash` or `--rebase`.

The merge commit produced by step 5 above is the artefact that closes ancestry. It carries **two parents**: (1) the dev branch head, and (2) the release squash commit on main. That two-parent relationship is exactly what makes `git merge-base --is-ancestor <release-squash> dev` return true — i.e., what makes future release PRs conflict-free.

Squash-merging this sync PR collapses the two-parent merge commit into a single-parent commit. The second parent (pointing at main's release squash) is permanently discarded. Dev gets main's *content* but the release squash is NOT an ancestor of dev. The `git log upstream/dev..upstream/main` divergence check will still show commits after squash — the exact failure this skill exists to prevent.

`/approve-merge` auto-detects `sync/`-prefixed PRs and uses `--merge` automatically. If merging via the CLI directly, pass `--merge` (not `--squash`, not `--rebase`):

```bash
gh pr merge <sync-pr-number> --repo me2resh/apexyard --merge --delete-branch
```

A guard in `block-unreviewed-merge.sh` will also refuse `--squash` on a `sync/`-prefixed PR to prevent accidental regression. See `AgDR-0053`.

Print:

```
Sync PR opened: <URL>
Branch: sync/main-to-dev-after-<version> → dev
Commits on main not yet on dev: N
Next step: /code-review, then /approve-merge once Rex approves.
IMPORTANT: /approve-merge will use --merge (not --squash) automatically for this sync PR.
After merge: git log upstream/dev..upstream/main should return empty.
After merge: git merge-base --is-ancestor <release-squash-sha> upstream/dev should return true.
```

## Edge Cases

| Scenario | Behaviour |
|----------|-----------|
| Already in sync (`dev..main` is empty) | Exit 0, print "Already in sync — no PR needed." |
| Tag does not exist yet | Warn "Tag <version> not found on upstream/main — has the release PR merged and been tagged?" then abort |
| Merge produces zero diff (all conflicts resolved to identical content) | Proceed — the merge commit itself is the artefact, even if the tree is identical to dev HEAD |
| Skill invoked on a managed project | Exit 1 with error "release-sync is framework-only" |
| Version not provided | Exit 1 with usage hint |
| Code is synced but `CHANGELOG.md` drifted (prior unfixed `/release-sync` run, manual main edit, etc.) | Step 2's `git log dev..main` may be empty yet step 5b's `git diff upstream/main -- CHANGELOG.md` is not. In that case create the sync branch from `upstream/dev`, skip the `-X ours` merge in step 5 (nothing to merge), run only step 5b's carry-forward commit, and open the PR with the body trimmed to the CHANGELOG-only summary. The PR is still useful — it surfaces the drift to a reviewer rather than letting the next `/release` silently truncate history. |
| Dev has in-flight edits to `CHANGELOG.md` between releases (unusual) | Carry-forward overwrites them. This is a recognised trade-off — under the release-cut model `dev` only receives CHANGELOG edits via `/release`. If you genuinely need a between-release CHANGELOG edit, land it via a chore PR before running `/release-sync` so the file is identical on `main` and `dev` by the time this skill runs. |

## Rules

1. **Framework-only.** Refuse on managed projects.
2. **No auto-merge.** The PR must go through Rex + CEO approval like every other PR.
3. **Branch base is always `upstream/dev`.** Never branch from main for this operation.
4. **`-X ours` merge strategy during branch build.** Dev wins on conflicts, always. Do not offer to flip this.
5. **`--merge` (true merge) on PR merge.** Never `--squash` or `--rebase`. The merge commit IS the ancestry-closure artefact; destroying it defeats the skill's purpose. `/approve-merge` enforces this automatically on `sync/`-prefixed PRs; a guard in `block-unreviewed-merge.sh` refuses `--squash` on them as a mechanical backstop.
6. **No-op on already-synced repos.** Idempotent: if main has nothing dev doesn't, exit 0.
7. **Version argument is required.** The version labels the sync branch and PR body for auditability.

## Related

- `/release` — the upstream skill that creates the squash divergence; invoke `/release-sync` as its final step
- `AgDR-0007` — the release-cut branch model this skill stabilises
- `AgDR-0052` — the original design decisions for this skill
- `AgDR-0053` — the decision to use `--merge` (not `--squash`) for sync PRs, and the auto-detect + guard design
- `docs/release-process.md` — the prose runbook

---

*Part of [ApexYard](https://github.com/me2resh/apexyard) — multi-project SDLC framework for Claude Code · MIT.*

