Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"api-client",
"sdk-generator"
],
"version": "1.17.0",
"version": "1.17.1",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
35 changes: 29 additions & 6 deletions scripts/ship.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
15 changes: 12 additions & 3 deletions tests/codegen/stripe-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);

Expand All @@ -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) {
Expand Down
140 changes: 140 additions & 0 deletions tests/ship-sync-branches.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});