diff --git a/package.json b/package.json index 0d733b3..9040255 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "api-client", "sdk-generator" ], - "version": "1.17.0", + "version": "1.17.1", "license": "MIT", "repository": { "type": "git", diff --git a/scripts/ship.sh b/scripts/ship.sh index 210fd84..3ceb42f 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -84,6 +84,33 @@ promote_issues() { || warn "Could not promote issue labels — release has landed; label by hand." } +# Sync local branches after a release, and push dev back to origin. +# +# The push is the part that used to be missing (#167). Without it, origin/dev +# stays at the pre-release commit while local dev sits at main's tip: the next +# `git pull origin dev` refuses with "Need to specify how to reconcile +# divergent branches", and anything that branches from origin/dev — a fresh +# clone, a worktree, the parallel issue fleet — silently starts from before +# the release. +# +# Never force. A rejected push means someone landed work on dev during the +# release window, and that work must not be discarded. Warn with the manual +# reconcile and carry on: the release has already published by the time this +# runs, so a sync hiccup must not fail the ship. +sync_branches() { + info "Syncing branches..." + git checkout main --quiet && git pull --quiet + git checkout dev --quiet && git rebase main --quiet + + if git push origin dev --quiet 2>/dev/null; then + ok "dev synced to origin" + else + warn "Could not fast-forward origin/dev — someone may have pushed to dev during the release." + warn "The release itself is fine. Reconcile by hand:" + warn " git checkout dev && git pull --rebase origin dev && git push origin dev" + fi +} + # Resolve a merged PR's merge commit into $MERGE_SHA. # # `gh pr merge` returns once the REST merge completes, but `gh pr view` reads @@ -209,9 +236,7 @@ resume_untagged_release() { tag_and_publish "$MERGE_SHA" "$main_version" promote_issues - info "Syncing branches..." - git checkout main --quiet && git pull --quiet - git checkout dev --quiet && git rebase main --quiet + sync_branches ok "Resumed and shipped v$main_version" exit 0 } @@ -450,8 +475,6 @@ promote_issues # ── Step 9: Cleanup ──────────────────────────────────────────────── -info "Syncing branches..." -git checkout main --quiet && git pull --quiet -git checkout dev --quiet && git rebase main --quiet +sync_branches ok "Shipped v$NEW_VERSION" diff --git a/tests/codegen/stripe-e2e.test.ts b/tests/codegen/stripe-e2e.test.ts index 841fe38..5d67b59 100644 --- a/tests/codegen/stripe-e2e.test.ts +++ b/tests/codegen/stripe-e2e.test.ts @@ -15,9 +15,16 @@ let commandsOutput: string; let commandMapOutput: string; beforeAll(async () => { - // Download Stripe spec — skip suite if offline + // Download Stripe spec — skip suite if unavailable (offline, rate-limited, + // or just slow). Two deadlines matter here and they must not be equal: + // the fetch is bounded well inside the hook's own timeout, so a slow or + // hanging response lands in the catch below instead of blowing the hook's + // deadline. A timed-out hook is reported as a hard test failure, not a + // skip — which is how a GitHub 429 on raw.githubusercontent.com (7-15s to + // respond, against bun's 5s default) once blocked a release even though + // this suite is designed to opt out when the spec can't be fetched. try { - const res = await fetch(STRIPE_SPEC_URL); + const res = await fetch(STRIPE_SPEC_URL, { signal: AbortSignal.timeout(20_000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -35,7 +42,9 @@ beforeAll(async () => { clientOutput = await Bun.file(join(outDir, 'client.ts')).text(); commandsOutput = await Bun.file(join(outDir, 'commands.ts')).text(); commandMapOutput = await Bun.file(join(outDir, 'command-map.ts')).text(); -}); + // Explicit hook timeout: the default 5s is shorter than a healthy download + // of this spec, let alone a throttled one. +}, 60_000); function skipIfOffline() { if (!spec) { diff --git a/tests/ship-sync-branches.test.ts b/tests/ship-sync-branches.test.ts new file mode 100644 index 0000000..67cb870 --- /dev/null +++ b/tests/ship-sync-branches.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { spawnSync } from 'child_process'; + +const repoRoot = join(import.meta.dir, '..'); +const shipPath = join(repoRoot, 'scripts', 'ship.sh'); + +/** + * #167 — ship.sh's post-release sync must push dev back to origin. + * + * Before the fix, sync_branches rebased local dev onto main and stopped, so + * origin/dev kept pointing at the pre-release commit. The next + * `git pull origin dev` refused with "divergent branches", and anything + * branching from origin/dev started from before the release. + * + * sync_branches is extracted from ship.sh rather than reimplemented, so these + * tests exercise the shipped code. Running ship.sh end-to-end is not an option + * here — it merges PRs and publishes to npm. + */ + +const HELPERS = ` +set -u +info() { echo "> $1"; } +ok() { echo "OK $1"; } +fail() { echo "FAIL $1"; } +warn() { echo "WARN $1"; } +eval "$(sed -n '/^sync_branches() {/,/^}/p' "$SHIP_PATH")" +`; + +interface RunResult { + stdout: string; + originDev: string; + originMain: string; + devLog: string; +} + +describe('#167 ship.sh sync_branches pushes dev to origin', () => { + let root: string; + + /** origin.git + a clone on `dev`, with `main` one release commit ahead. */ + function setupRepos(): string { + const work = join(root, 'work'); + const script = join(root, 'setup.sh'); + + writeFileSync(script, ` +set -eu +cd "${root}" +git init -q --bare origin.git +git clone -q origin.git work +cd work +git config user.email t@t +git config user.name t +echo a > f +git add f +git commit -qm init +git branch -M main +git push -q -u origin main +git checkout -qb dev +git push -q -u origin dev +git checkout -q main +echo b > f +git commit -qam "chore(release): v9.9.9" +git push -q origin main +git checkout -q dev +`); + spawnSync('bash', [script], { encoding: 'utf-8' }); + + return work; + } + + function runSync(work: string, extra = ''): RunResult { + const script = join(root, 'run.sh'); + + writeFileSync(script, ` +export SHIP_PATH="${shipPath}" +cd "${work}" +${extra} +${HELPERS} +sync_branches +echo "EXIT=$?" +git fetch -q origin +echo "ORIGIN_DEV=$(git rev-parse origin/dev)" +echo "ORIGIN_MAIN=$(git rev-parse origin/main)" +echo "DEV_LOG=$(git log origin/dev --oneline | tr '\\n' '|')" +`); + const r = spawnSync('bash', [script], { encoding: 'utf-8' }); + const out = `${r.stdout}${r.stderr}`; + const pick = (k: string): string => out.match(new RegExp(`${k}=(.*)`))?.[1]?.trim() ?? ''; + + return { + stdout: out, + originDev: pick('ORIGIN_DEV'), + originMain: pick('ORIGIN_MAIN'), + devLog: pick('DEV_LOG'), + }; + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'apijack-sync-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test('normal post-release: origin/dev is fast-forwarded to main', () => { + const work = setupRepos(); + const r = runSync(work); + + expect(r.stdout).toContain('dev synced to origin'); + expect(r.originDev).toBe(r.originMain); + expect(r.originDev).not.toBe(''); + }); + + test('contended dev: push is rejected, never forced, and the ship still succeeds', () => { + const work = setupRepos(); + + // Someone lands work on dev during the release window, then ship.sh's + // rebase leaves local dev at main's tip — the push cannot fast-forward. + const intruder = ` +git checkout -q dev +echo intruder > g +git add g +git commit -qm "landed during release" +git push -q origin dev +git reset -q --hard origin/main +`; + const r = runSync(work, intruder); + + expect(r.stdout).toContain('Could not fast-forward origin/dev'); + expect(r.stdout).toContain('git pull --rebase origin dev'); + // Must not fail the ship: the release has already published by now. + expect(r.stdout).toContain('EXIT=0'); + // The other person's commit must survive — no force-push. + expect(r.devLog).toContain('landed during release'); + expect(r.originDev).not.toBe(r.originMain); + }); +});