|
| 1 | +--- |
| 2 | +name: syncing-primer-fork |
| 3 | +description: Use when syncing this OpenProject fork with its upstream Primer repo (primer/view_components or primer/octicons) — pulling new components, merging upstream changes, resolving dependency/lockfile conflicts, or running script/merge-upstream. |
| 4 | +--- |
| 5 | + |
| 6 | +# Syncing a Primer Fork from Upstream |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +The `@openproject/*` repos (view_components, octicons) are forks of `primer/*`. |
| 11 | +Syncing means merging upstream changes onto a `bump/primer-upstream` branch via |
| 12 | +`script/merge-upstream`, **one upstream release batch at a time**, stopping just |
| 13 | +before each version-bump commit, then resolving dependency conflicts per package |
| 14 | +manager. |
| 15 | + |
| 16 | +**Core principle:** Sync the *oldest* unmerged release batch first. Merge the |
| 17 | +*parent* of the oldest unmerged version-bump commit whose parent isn't already |
| 18 | +in `main` — never a version-bump commit itself, and never skip ahead to the |
| 19 | +latest one. |
| 20 | + |
| 21 | +**Why the oldest, not the latest:** a version-bump commit (changesets bot) |
| 22 | +*deletes* the `.changeset/*.md` files it consumes. If you jump to the parent of |
| 23 | +the *latest* version-bump, every intervening version-bump in that range has |
| 24 | +already deleted its changesets — so the fork loses them and can't regenerate |
| 25 | +those changelog entries with its own `changeset:version`. Stopping before the |
| 26 | +*oldest* unmerged version-bump keeps that batch's changesets intact. Repeat per |
| 27 | +batch. |
| 28 | + |
| 29 | +## One-time prerequisite: register the `ours` merge driver |
| 30 | + |
| 31 | +`.gitattributes` marks `CHANGELOG.md`, `Gemfile.lock`, `demo/Gemfile.lock`, and |
| 32 | +`lib/primer/view_components/version.rb` with `merge=ours`, but that driver is |
| 33 | +**inert unless registered in git config**. Without it those files conflict like |
| 34 | +any other. Run once per clone (or set `--global`): |
| 35 | + |
| 36 | +```bash |
| 37 | +git config merge.ours.driver true |
| 38 | +``` |
| 39 | + |
| 40 | +Verify: `git config merge.ours.driver` prints `true`. |
| 41 | + |
| 42 | +## Procedure |
| 43 | + |
| 44 | +### 1. Pick the SHA — parent of the *oldest* unmerged version-bump commit |
| 45 | + |
| 46 | +Walk the unmerged version-bump commits oldest-first and merge the parent of the |
| 47 | +first one whose parent isn't already in `main`. (`main` usually sits exactly at |
| 48 | +the previous batch's boundary, i.e. the parent of the *oldest* unmerged |
| 49 | +version-bump — that's a no-op, so skip it and take the next.) |
| 50 | + |
| 51 | +- view_components: version-bump commits are titled **`Release tracking`** |
| 52 | +- octicons: titled **`Version Packages`** |
| 53 | + |
| 54 | +```bash |
| 55 | +git fetch upstream |
| 56 | +GREP="Release tracking" # octicons: "Version Packages" |
| 57 | +for vp in $(git log upstream/main --grep="$GREP" --not main --reverse --format=%H); do |
| 58 | + parent=$(git rev-parse "$vp^") |
| 59 | + git merge-base --is-ancestor "$parent" main && continue # parent already merged → skip |
| 60 | + echo "TARGET: $parent ($(git log -1 --format='%h %s' "$vp")'s parent)" |
| 61 | + break |
| 62 | +done |
| 63 | +``` |
| 64 | + |
| 65 | +That `TARGET` SHA is what you pass to the script. Each run advances one batch; |
| 66 | +re-run after the fork has versioned/released the previous batch. |
| 67 | + |
| 68 | +### 2. Run the merge script |
| 69 | + |
| 70 | +```bash |
| 71 | +script/merge-upstream <TARGET> gsed # the SHA from step 1 |
| 72 | +``` |
| 73 | + |
| 74 | +On macOS pass `gsed` as the 2nd arg (the script auto-detects, but be explicit). |
| 75 | +The script: fetches upstream, builds `bump/primer-upstream-ref` (reset to the |
| 76 | +SHA) and `bump/primer-upstream` (from `origin/main`), merges with `--no-commit`, |
| 77 | +rescopes `@primer/view-components` → `@openproject/primer-view-components` in |
| 78 | +`.changeset/*.md`, then runs `script/setup`, stages, and stops at an interactive |
| 79 | +`git commit`. |
| 80 | + |
| 81 | +**The merge step is conflict-tolerant (`|| true`), but the later `script/setup` is |
| 82 | +not.** `script/setup` runs `bundle install` and `npm install`; when a `Gemfile`, |
| 83 | +`*.gemspec`, or `package.json` conflicts (almost always — fork scope/version vs |
| 84 | +upstream), the conflict markers make those installs fail → `set -e` aborts the |
| 85 | +script *before* stage/commit. That's expected: the changeset rescope has already |
| 86 | +run, and you're left mid-merge. **Resolve conflicts (step 3) first, then run |
| 87 | +`script/setup` yourself**, then stage and commit. |
| 88 | + |
| 89 | +### 3. Resolve conflicts by package manager |
| 90 | + |
| 91 | +With the `ours` driver registered (see prerequisite), `.gitattributes` resolves |
| 92 | +`CHANGELOG.md`, `Gemfile.lock`, `demo/Gemfile.lock`, and the gem `version.rb` |
| 93 | +automatically (fork wins). For the rest: |
| 94 | + |
| 95 | +| Conflict in | Manager | Fix | |
| 96 | +|---|---|---| |
| 97 | +| `Gemfile` / `*.gemspec` | **bundler** | Resolve the manifest, then `bundle install` (root and in `demo/`) to bring the `Gemfile.lock`s in sync. `bundle install` does **not** resolve conflict markers in a `Gemfile.lock` itself — those are handled by `merge=ours`; if any ever appear (driver not registered), `git checkout --ours <Gemfile.lock>` first, then `bundle install`. | |
| 98 | +| `package.json` (root or `demo/`) | **npm** | Resolve `package.json`, then `npm install` in that dir to regenerate `package-lock.json` | |
| 99 | +| component source, `.changeset/*.md`, `previews/`, docs | — | Resolve normally; keep upstream's new components | |
| 100 | + |
| 101 | +`script/setup` does both (`bundle install` + `npm install`, root and `demo/`, then |
| 102 | +`rake docs:build`), so once conflicts are resolved a single `script/setup` refreshes |
| 103 | +every lockfile and the generated docs. The mirror repo (octicons) instead uses |
| 104 | +yarn at the root plus npm for `octicons_angular`. |
| 105 | + |
| 106 | +After resolving, re-stage, run setup, and commit: |
| 107 | + |
| 108 | +```bash |
| 109 | +git add -A |
| 110 | +script/setup # regenerates lockfiles + docs from the merged sources |
| 111 | +git add -A |
| 112 | +git commit # finishes the deferred merge commit |
| 113 | +``` |
| 114 | + |
| 115 | +### 4. Audit dependency bumps dropped by `merge=ours` |
| 116 | + |
| 117 | +Because `Gemfile.lock` is `merge=ours`, any upstream bump made by `bundle update |
| 118 | +<gem>` **alone** — no `Gemfile` change, the common shape for Dependabot patch and |
| 119 | +transitive bumps like `nokogiri` or `rubocop` — is discarded on merge. The fork |
| 120 | +silently stays on the old locked version. `package-lock.json` is *not* `merge=ours` |
| 121 | +(it 3-way merges), but `npm install` re-resolving to the fork's manifest ranges can |
| 122 | +still lock a **lower** version than upstream had — the same "missing bump" failure. |
| 123 | + |
| 124 | +The fork's own weekly Dependabot (`.github/dependabot.yml`) re-applies most of these |
| 125 | +eventually, but it's cooldown-gated and its grouping/ignore rules don't mirror |
| 126 | +upstream's — so security bumps shouldn't wait for it. After the merge, before you |
| 127 | +commit, list what upstream locked that the fork didn't pick up: |
| 128 | + |
| 129 | +```bash |
| 130 | +# gems: '+' side is upstream's TARGET, '-' is the fork's merged result |
| 131 | +git diff bump/primer-upstream..<TARGET> -- Gemfile.lock demo/Gemfile.lock |
| 132 | +# npm (noisier — scan the "version" lines for shared packages) |
| 133 | +git diff bump/primer-upstream..<TARGET> -- package-lock.json demo/package-lock.json |
| 134 | +``` |
| 135 | + |
| 136 | +For each bump worth replaying now — **security first** — re-apply it within the |
| 137 | +fork's constraints, then re-stage: |
| 138 | + |
| 139 | +```bash |
| 140 | +bundle update <gem> --conservative # root; add BUNDLE_GEMFILE=demo/Gemfile for demo |
| 141 | +npm install <pkg>@<version> # run in the dir that owns the lockfile |
| 142 | +``` |
| 143 | + |
| 144 | +`--conservative` limits the change to that one gem instead of dragging in unrelated |
| 145 | +transitive bumps. If the fork's `Gemfile` constraint *forbids* upstream's version, |
| 146 | +that's a manifest bump (resolve in step 3), not a lockfile-only replay — leave it to |
| 147 | +the fork's Dependabot. Leave everything non-urgent to the weekly Dependabot cycle. |
| 148 | +These are dependency bumps, so no changeset (matches the `skip changeset` label the |
| 149 | +fork's Dependabot uses). |
| 150 | + |
| 151 | +## Quick Reference |
| 152 | + |
| 153 | +```bash |
| 154 | +git fetch upstream |
| 155 | +# TARGET = parent of the OLDEST unmerged version-bump whose parent isn't in main: |
| 156 | +for vp in $(git log upstream/main --grep="Release tracking" --not main --reverse --format=%H); do |
| 157 | + p=$(git rev-parse "$vp^"); git merge-base --is-ancestor "$p" main && continue; echo "$p"; break |
| 158 | +done |
| 159 | +script/merge-upstream <TARGET> gsed |
| 160 | +# resolve conflicts: Gemfile→bundle install, package.json→npm install, others normal |
| 161 | +# audit dropped bumps (merge=ours discards upstream Gemfile.lock-only bumps): |
| 162 | +git diff bump/primer-upstream..<TARGET> -- Gemfile.lock demo/Gemfile.lock |
| 163 | +script/setup && git add -A && git commit |
| 164 | +``` |
| 165 | + |
| 166 | +## Common Mistakes |
| 167 | + |
| 168 | +- **Jumping to the parent of the *latest* version-bump.** That drags in every |
| 169 | + intervening version-bump, which already deleted its changesets — the fork loses |
| 170 | + them. Sync the *oldest* unmerged batch first (see Core principle). |
| 171 | +- **Merging a version-bump commit itself** (e.g. `Release tracking`) instead of |
| 172 | + its parent — pulls upstream's bump and conflicts with the fork's `changeset:version`. |
| 173 | +- **Assuming the changeset rename worked.** Upstream changesets use *single* |
| 174 | + quotes (`'@primer/view-components'`); a double-quote-only sed silently no-ops. |
| 175 | + The script matches either quote (and a trailing `[^-]` guards any |
| 176 | + `@primer/view-components-*` sibling). Verify after: no `@primer/view-components'` |
| 177 | + or `@primer/view-components"` remains in `.changeset/`. |
| 178 | +- **Forgetting `gsed` on macOS.** BSD `sed -i` needs a backup-suffix arg; the |
| 179 | + changeset rename silently misbehaves without a GNU-compatible sed. |
| 180 | +- **Trusting hand-merged / rerere-resolved YAML by eye.** Merges in `.github/*.yml` |
| 181 | + (workflows, `dependabot.yml`) easily produce subtly broken YAML — wrong list-item |
| 182 | + indentation, a key dropped to the wrong level, duplicate keys, a mapping/sequence |
| 183 | + mismatch. `rerere` can also replay a resolution from a *different* merge context |
| 184 | + and reintroduce upstream's quirks (e.g. list items flush with their key). Always |
| 185 | + re-validate after resolving: `ruby -ryaml -e 'YAML.load_file(ARGV[0])' <file>` |
| 186 | + (or `yq` / `python3 -c`), and confirm the *structure* parsed as intended — that a |
| 187 | + list is a list and sibling keys sit at the right depth, not just that it loads. |
| 188 | +- **Assuming rerere makes the sync reproducible.** `rerere.enabled` is usually set |
| 189 | + *globally* (`~/.gitconfig`), and `.git/rr-cache` is **per-clone and never |
| 190 | + committed** — so the same upstream batch resolves *differently* for different |
| 191 | + people, and every replay lands silently in the working tree (all files, not just |
| 192 | + YAML; with `rerere.autoupdate` it's even auto-staged). Diff every rerere-touched |
| 193 | + hunk before staging; if a replayed resolution looks stale, `git rerere forget |
| 194 | + <path>` and resolve it by hand. |
| 195 | +- **Inventing a PR/release flow.** The job ends at the local merge commit on |
| 196 | + `bump/primer-upstream`; release is a separate changeset-driven process. |
| 197 | + |
| 198 | +## Keeping the two forks aligned |
| 199 | + |
| 200 | +This skill and `script/merge-upstream` are mirrored between view_components and |
| 201 | +octicons. When you change one, port the change to the other. The repo |
| 202 | +differences to account for: |
| 203 | + |
| 204 | +- **Commit-title grep term:** `Release tracking` (view_components) vs |
| 205 | + `Version Packages` (octicons). |
| 206 | +- **Package managers / lockfiles:** view_components uses Ruby Bundler |
| 207 | + (`Gemfile.lock`, `demo/Gemfile.lock`) plus npm (`package-lock.json`, |
| 208 | + `demo/package-lock.json`), driven by `script/setup`; octicons uses yarn at the root |
| 209 | + plus npm for `octicons_angular`. |
| 210 | +- **npm scope:** `@primer/view-components` → `@openproject/primer-view-components` |
| 211 | + (octicons: `@primer/octicons` → `@openproject/octicons`). |
0 commit comments