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 Docs/Resources/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This is the release workflow for `rate.vn`. It follows the same model as DOS/DOS

## Normal release

1. Feature PRs go into `dev` as usual: review, green required checks (`Typecheck & Build Monorepo`, `E2E (Playwright)`, `Secret scan (gitleaks)`, `task-claim`), squash merge.
1. Feature PRs go into `dev` as usual: review, green required checks (`Typecheck & Build Monorepo`, `E2E (Playwright)`, `Secret scan (gitleaks)`, `task-claim`), squash merge. `node scripts/ops/merge-when-green.mjs <pr>` writes the squash commit's message itself: the PR title with `(#<pr>)`, then each commit wrapped at 100 characters, with the co-author trailers in a footer. It refuses a PR whose title is not a conventional header or does not fit `<title> (#<pr>)` in 100 characters: shorten the title. The promotion PR's `commitlint` check lints the head of `dev`, which is usually that squash commit, so it must pass `commitlint.config.ts` (DOS/Rate#1130).
2. Check `dev` on its Vercel Preview deployment.
3. Open the promotion PR from `dev` itself, not from a copy branch:
`gh pr create --repo DOS/Rate --base main --head dev --title "chore(release): promote dev to main"`
Expand Down
3 changes: 2 additions & 1 deletion scripts/ops/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url';

export const DEFAULT_REPO = 'DOS/Rate';

export function gh(args, { allowFailure = false } = {}) {
export function gh(args, { allowFailure = false, input } = {}) {
const r = spawnSync('gh', args, {
encoding: 'utf8',
input,
maxBuffer: 64 * 1024 * 1024,
});
if (r.error) throw r.error;
Expand Down
159 changes: 154 additions & 5 deletions scripts/ops/merge-when-green.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,110 @@ export function shouldDeleteBranch({ method, headRef, headRepo, baseRepo }) {
);
}

/**
* commitlint's limit (commitlint.config.ts) for the header and for every body
* and footer line. The commitlint check of a dev to main promotion lints the
* head of dev, which is usually a squash commit made here (DOS/Rate#1130).
*/
export const MAX_LINE = 100;
// commitlint exempts any line holding a URL from the line limit.
const URL_LINE = /\bhttps?:\/\/\S+/;
const TRAILER = /^(?:co-authored-by|signed-off-by):\s*\S/i;
const LIST_MARKER = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?/;

/**
* One line as lines of at most `width` characters: words wrap under the text
* of a list item, and a word longer than a whole line is split.
*/
export function wrapLine(line, width = MAX_LINE) {
if (!(width >= 2)) throw new RangeError(`cannot wrap to width ${width}`);
if (line.length <= width || URL_LINE.test(line)) return [line];
const marker = line.match(LIST_MARKER)[0];
const first = marker.length <= width / 2 ? marker : '';
const hang = ' '.repeat(first.length);
const lines = [];
let current = first;
let empty = true;
for (let word of line.slice(first.length).split(/\s+/).filter(Boolean)) {
for (;;) {
const sep = empty ? '' : ' ';
if (current.length + sep.length + word.length <= width) {
current += sep + word;
empty = false;
break;
}
if (empty) {
// Not even an empty line holds the word: split it.
// Cut between characters, never inside a surrogate pair.
const room = width - current.length;
let piece = '';
for (const char of word) {
if (piece.length + char.length > room) break;
piece += char;
}
if (piece === '') piece = word.slice(0, Math.max(room, 1));
lines.push(current + piece);
word = word.slice(piece.length);
current = hang;
continue;
}
lines.push(current);
current = hang;
empty = true;
}
}
if (!empty) lines.push(current);
return lines;
}

// `type(scope)!: subject`, the header shape commitlint's parser reads.
const CONVENTIONAL_HEADER = /^[a-z]+(?:\([^()\r\n]*\))?!?: \S/;

/**
* The squash commit's header: the PR title with its number, which promote.mjs
* reads back. Null when the title is not a conventional header or the header
* is over the limit: cutting it could leave an empty or start-case subject.
*/
export function squashTitle(title, number) {
const text = title.trim().replace(/\s+/g, ' ');
const header = `${text} (#${number})`;
return CONVENTIONAL_HEADER.test(text) && header.length <= MAX_LINE
? header
: null;
}

/**
* The squash commit's body, like GitHub's default (each commit as a list
* item), with every line wrapped to the limit and the commits' co-author and
* sign-off trailers gathered once each into a footer after a blank line.
*/
export function squashBody(messages) {
const trailers = new Map();
const items = [];
for (const message of messages) {
const lines = [];
for (const line of message.replace(/\r\n?/g, '\n').split('\n')) {
if (TRAILER.test(line.trim())) {
const trailer = line.trim().replace(/:\s*/, ': ');
const key = trailer.toLowerCase();
if (!trailers.has(key)) trailers.set(key, trailer);
} else {
lines.push(line.trimEnd());
}
}
while (lines.length > 0 && lines.at(-1) === '') lines.pop();
while (lines.length > 0 && lines[0] === '') lines.shift();
if (lines.length === 0) continue;
lines[0] = `* ${lines[0]}`;
items.push(lines.flatMap((line) => wrapLine(line)).join('\n'));
}
// A wrapped trailer no longer credits anyone: leave out one too long.
const footer = [...trailers.values()].filter(
(line) => line.length <= MAX_LINE
);
return [items.join('\n\n'), footer.join('\n')].filter(Boolean).join('\n\n');
}

/**
* What to do with the PR now: 'merge', 'wait', or a final 'done' / 'fail'
* with a one-line message.
Expand Down Expand Up @@ -210,6 +314,7 @@ export async function mergeWhenGreen({
let blockedPolls = 0;
let apiErrors = 0;
let mergeRefusals = 0;
let commitListErrors = 0;
let branchUpdates = 0;
let lastUpdatedSha = null;
for (;;) {
Expand Down Expand Up @@ -249,6 +354,12 @@ export async function mergeWhenGreen({
message: `PR ${number}: cannot --ignore required check(s) ${conflict.join(', ')}`,
};
}
if (method === 'squash' && !squashTitle(pr.title, number)) {
return {
ok: false,
message: `PR ${number}: its title must be a conventional header that fits "<title> (#${number})" in ${MAX_LINE} characters, so the squash commit passes commitlint`,
};
}
}
const checks = evaluateChecks(head.runs, head.statuses, {
ignore: [...IGNORED_CHECKS, ...ignore],
Expand Down Expand Up @@ -293,20 +404,58 @@ export async function mergeWhenGreen({
continue;
}
if (next.action === 'merge') {
// GitHub's default squash message keeps the commits' long lines and
// mixes their trailers into the body: send one commitlint accepts.
const squash = {};
if (method === 'squash') {
let messages;
try {
messages = ghPaginated(
`repos/${repo}/pulls/${number}/commits?per_page=100`,
'.[] | {message: .commit.message}'
).map((c) => c.message);
commitListErrors = 0;
} catch (err) {
if (++commitListErrors >= MAX_API_ERRORS) {
return {
ok: false,
message: `PR ${number}: cannot list its commits: ${err.message}`,
};
}
await sleep(intervalMs);
continue;
}
// The commits API stops at 250: never write a body that drops some.
if (messages.length < pr.commits) {
return {
ok: false,
message: `PR ${number}: only ${messages.length} of its ${pr.commits} commits could be listed for the squash message`,
};
}
squash.commit_title = squashTitle(pr.title, number);
squash.commit_message = squashBody(messages);
}
// The fields go as JSON on stdin: a long squash message is over the
// Windows command line limit.
const merged = gh(
[
'api',
'-X',
'PUT',
`repos/${repo}/pulls/${number}/merge`,
'-f',
`merge_method=${method}`,
'-f',
`sha=${pr.head.sha}`,
'--input',
'-',
'--jq',
'.sha',
],
{ allowFailure: true }
{
allowFailure: true,
input: JSON.stringify({
merge_method: method,
sha: pr.head.sha,
...squash,
}),
}
);
if (merged.status !== 0) {
// A check re-queued after the last read (for example by a PR edit)
Expand Down
107 changes: 107 additions & 0 deletions scripts/ops/merge-when-green.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

import {
Expand All @@ -7,9 +9,13 @@ import {
evaluateChecks,
ignoredRequired,
latestRuns,
MAX_LINE,
mergeMethodFor,
requiredChecksFromRules,
shouldDeleteBranch,
squashBody,
squashTitle,
wrapLine,
} from './merge-when-green.mjs';
import { encodeRef } from './lib.mjs';

Expand Down Expand Up @@ -291,3 +297,104 @@ test('gives a green but blocked PR a few polls before reporting it', () => {
assert.equal(last.action, 'fail');
assert.match(last.message, /required review or check/);
});

// The commits of DOS/Rate#1127, whose default squash failed commitlint on the
// promotion PR DOS/Rate#1128 (DOS/Rate#1130).
const CO_AUTHOR = 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>';
const LONG_COMMITS = [
`fix(shared-data): earbuds regex reads glued brands and drops over-ear titles that say tai nghe (#1125)

Any brand glued to Buds, FreeBuds, AirPods or WF1000XM now reads as earbuds
(PixelBudsPro2, HuaweiFreeBudsPro4, SonyWF1000XM5), as do AirPodsPro2 and
two glued tiers (Buds3ProFE).

${CO_AUTHOR}`,
`fix(shared-data): over-ear WH with a space or underscore, glued Redmi/Samsung/Enco/Tune Buds (#1125)

Review follow-up on PR #1127, with a reference line that is far longer than one hundred characters in total.
See https://github.com/DOS/Rate/pull/1127#issuecomment-1234567890 for the review thread and the whole discussion.
A path with no spaces: ${'packages/shared-data/src/'.repeat(6)}earbuds.ts

${CO_AUTHOR}`,
];
const LONG_TITLE =
'fix(shared-data): earbuds regex reads glued brands, drops over-ear tai nghe titles (#1125)';

const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
/** Lints a message with the repository's commitlint.config.ts. */
function commitlint(message) {
const r = spawnSync(
process.execPath,
['node_modules/@commitlint/cli/cli.js'],
{ cwd: repoRoot, input: message, encoding: 'utf8' }
);
assert.equal(r.error, undefined);
return { ok: r.status === 0, output: r.stdout + r.stderr };
}

test('wraps a long line under its list marker and splits a word longer than a line', () => {
const words = Array.from({ length: 40 }, (_, i) => `word${i}`).join(' ');
const wrapped = wrapLine(`* ${words}`);
assert.ok(wrapped.length > 1);
assert.ok(wrapped.every((line) => line.length <= MAX_LINE));
assert.ok(wrapped.slice(1).every((line) => line.startsWith(' w')));
assert.equal(wrapped.join(' ').replace(/\s+/g, ' '), `* ${words}`);
const word = 'x'.repeat(250);
assert.equal(wrapLine(word).join(''), word);
assert.ok(wrapLine(word).every((line) => line.length <= MAX_LINE));
assert.deepEqual(wrapLine('short'), ['short']);
const url = `see https://example.com/${'a'.repeat(120)}`;
assert.deepEqual(wrapLine(url), [url]);
});

test('splits a long word between characters, never inside an emoji', () => {
const wrapped = wrapLine(`a${'😀'.repeat(50)}`);
assert.ok(wrapped.every((line) => line.length <= MAX_LINE));
assert.ok(wrapped.every((line) => !/[\uD800-\uDBFF]$/.test(line)));
assert.equal(wrapped.join(''), `a${'😀'.repeat(50)}`);
assert.throws(() => wrapLine('x y', 0), RangeError);
});

test('keeps the PR number in the squash title, or refuses a title that cannot carry it', () => {
assert.equal(
squashTitle(' fix(web): a thing ', 7),
'fix(web): a thing (#7)'
);
assert.equal(squashTitle(LONG_TITLE, 1127), `${LONG_TITLE} (#1127)`);
// Cutting these would leave an empty subject or a start-case one.
assert.equal(squashTitle(`${LONG_TITLE} and more`, 1127), null);
assert.equal(squashTitle(`fix: ${'a'.repeat(89)}`, 1133), null);
// Not conventional: commitlint ignores `v1.2.3` alone, not with `(#n)`.
assert.equal(squashTitle('v1.2.3', 1133), null);
assert.equal(squashTitle('Update stuff', 1133), null);
assert.equal(
squashTitle('feat(web)!: drop old route', 3),
'feat(web)!: drop old route (#3)'
);
});

test('gathers the trailers once each into a footer after the commits', () => {
const body = squashBody(LONG_COMMITS);
assert.ok(body.startsWith('* fix(shared-data): earbuds regex'));
assert.ok(body.endsWith(`\n\n${CO_AUTHOR}`));
assert.equal(body.split(CO_AUTHOR).length, 2);
assert.ok(body.includes('\n\n* fix(shared-data): over-ear WH'));
assert.equal(squashBody(['fix: a\r\n\r\nbody\r\n']), '* fix: a\n\nbody');
// A trailer is never wrapped: one too long for commitlint is left out.
const long = `Co-authored-by: ${'a'.repeat(90)} <a@b.com>`;
assert.equal(
squashBody([`fix: a\n\n${long}\n${CO_AUTHOR}`]),
`* fix: a\n\n${CO_AUTHOR}`
);
});

test('the squash message of PR bodies with lines over 100 characters passes commitlint', () => {
const header = squashTitle(LONG_TITLE, 1127);
const message = `${header}\n\n${squashBody(LONG_COMMITS)}\n`;
// GitHub's default squash of the same commits fails, so the test has teeth.
const github = `${header}\n\n${LONG_COMMITS.map((m) => `* ${m}`).join('\n\n')}\n`;
assert.equal(commitlint(github).ok, false);
const result = commitlint(message);
assert.ok(result.ok, result.output);
assert.doesNotMatch(result.output, /warning/);
});
Loading