Skip to content

Commit 7ef0671

Browse files
authored
fix(release): verify release-please product tags (#83)
1 parent 54e3595 commit 7ef0671

6 files changed

Lines changed: 149 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,12 @@ jobs:
492492
manifest-file: .release-please-manifest.json
493493
skip-github-pull-request: true
494494

495+
- name: Verify release-please product tags
496+
if: ${{ inputs.operation == 'publish' && steps.release_plan.outputs.has_release_changes == 'true' }}
497+
env:
498+
PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }}
499+
run: tools/dev/bun.sh tools/release/verify_product_tags.mjs --products-json "${PRODUCTS_JSON}" --target "$RELEASE_HEAD_SHA"
500+
495501
- name: Remove release-please target branch
496502
if: ${{ always() && inputs.operation == 'publish' && steps.release_head.outputs.uses_temporary_target_branch == 'true' }}
497503
continue-on-error: true

release-please-config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"include-v-in-tag": true,
44
"tag-separator": "-",
55
"pull-request-title-pattern": "chore${scope}: release${component} ${version}",
6-
"group-pull-request-title-pattern": "chore(release): prepare product releases",
6+
"group-pull-request-title-pattern": "chore(release): prepare ${branch} releases",
77
"initial-version": "0.1.0",
88
"bump-minor-pre-major": true,
99
"bump-patch-for-minor-pre-major": true,

tools/policy/check-release-policy.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,7 @@ function checkReleaseWorkflowPolicy() {
11741174
"Validate selected release product dry-runs",
11751175
"Create release-please target branch",
11761176
"Create release-please GitHub releases",
1177+
"Verify release-please product tags",
11771178
"Remove release-please target branch",
11781179
"Publish liboliphaunt GitHub release assets",
11791180
],
@@ -1192,6 +1193,8 @@ function checkReleaseWorkflowPolicy() {
11921193
"--artifact liboliphaunt-native-release-assets",
11931194
'--artifact "$artifact"',
11941195
"PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }}",
1196+
"Verify release-please product tags",
1197+
'tools/dev/bun.sh tools/release/verify_product_tags.mjs --products-json "${PRODUCTS_JSON}" --target "$RELEASE_HEAD_SHA"',
11951198
'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-products --family sdk-package --products-json "$PRODUCTS_JSON" --format lines',
11961199
'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines',
11971200
'tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines',
@@ -1401,6 +1404,7 @@ function checkReleaseWorkflowPolicy() {
14011404
const guardedPublishSteps = new Set([
14021405
"Create release-please target branch",
14031406
"Create release-please GitHub releases",
1407+
"Verify release-please product tags",
14041408
"Remove release-please target branch",
14051409
"Publish liboliphaunt GitHub release assets",
14061410
"Publish selected extension GitHub release assets",

tools/release/check_release_please_config.mjs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.
77
const configPath = path.join(root, 'release-please-config.json');
88
const manifestPath = path.join(root, '.release-please-manifest.json');
99
const decoder = new TextDecoder();
10+
const RELEASE_PR_TITLE_PATTERN = 'chore${scope}: release${component} ${version}';
11+
const GROUP_RELEASE_PR_TITLE_PATTERN = 'chore(release): prepare ${branch} releases';
1012

1113
function fail(message) {
1214
console.error(`check_release_please_config.mjs: ${message}`);
@@ -259,6 +261,39 @@ function validatePlugins(plugins, pathsById) {
259261
);
260262
}
261263
}
264+
265+
function releasePleaseTitlePatternRegex(pattern) {
266+
if (typeof pattern !== 'string' || !pattern) {
267+
fail(`release-please title pattern must be a non-empty string: ${JSON.stringify(pattern)}`);
268+
}
269+
return new RegExp(
270+
`^${pattern
271+
.replace('[', '\\[')
272+
.replace(']', '\\]')
273+
.replace('(', '\\(')
274+
.replace(')', '\\)')
275+
.replace('${scope}', '(\\((?<branch>[\\w-./]+)\\))?')
276+
.replace('${component}', ' ?(?<component>@?[\\w-./]*)?')
277+
.replace('${version}', 'v?(?<version>[0-9].*)')
278+
.replace('${branch}', '(?<branch>[\\w-./]+)?')}$`,
279+
);
280+
}
281+
282+
function assertParseableReleasePleaseTitle(pattern, title, context) {
283+
const match = title.match(releasePleaseTitlePatternRegex(pattern));
284+
if (!match?.groups) {
285+
fail(`${context} must be parseable by release-please: ${JSON.stringify(title)}`);
286+
}
287+
}
288+
289+
function renderReleasePleaseTitle(pattern, { targetBranch }) {
290+
return pattern
291+
.replace('${scope}', targetBranch ? `(${targetBranch})` : '')
292+
.replace('${component}', '')
293+
.replace('${version}', '')
294+
.replace('${branch}', targetBranch ?? '')
295+
.trim();
296+
}
262297
if (actualPaths.size !== expectedPaths.size || sortedDifference(expectedPaths, actualPaths).length > 0) {
263298
fail(
264299
`release-please packages must match release products:\nmissing=${JSON.stringify(sortedDifference(expectedPaths, actualPaths))}\nextra=${JSON.stringify(sortedDifference(actualPaths, expectedPaths))}`,
@@ -276,9 +311,26 @@ if (config['tag-separator'] !== '-') {
276311
if (config['include-v-in-tag'] !== true) {
277312
fail('release-please must include v in tags');
278313
}
279-
if (config['pull-request-title-pattern'] !== 'chore${scope}: release${component} ${version}') {
314+
if (config['pull-request-title-pattern'] !== RELEASE_PR_TITLE_PATTERN) {
280315
fail("release-please pull-request-title-pattern must keep release-please's parseable default shape");
281316
}
317+
if (config['group-pull-request-title-pattern'] !== GROUP_RELEASE_PR_TITLE_PATTERN) {
318+
fail('release-please group-pull-request-title-pattern must keep grouped release PRs parseable');
319+
}
320+
const generatedGroupTitle = renderReleasePleaseTitle(GROUP_RELEASE_PR_TITLE_PATTERN, { targetBranch: 'main' });
321+
if (generatedGroupTitle !== 'chore(release): prepare main releases') {
322+
fail(`release-please grouped release PR title rendered unexpectedly: ${JSON.stringify(generatedGroupTitle)}`);
323+
}
324+
assertParseableReleasePleaseTitle(
325+
GROUP_RELEASE_PR_TITLE_PATTERN,
326+
generatedGroupTitle,
327+
'generated grouped release PR title',
328+
);
329+
assertParseableReleasePleaseTitle(
330+
GROUP_RELEASE_PR_TITLE_PATTERN,
331+
'chore(release): prepare product releases',
332+
'already-merged grouped release PR #80 title',
333+
);
282334
if (config['initial-version'] !== '0.1.0') {
283335
fail('release-please initial-version must bootstrap the first generated release PR to 0.1.0');
284336
}

tools/release/verify_product_tag.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function git(args, { check = true } = {}) {
4848
return {
4949
exitCode: result.exitCode,
5050
stdout: decoder.decode(result.stdout).trim(),
51+
stderr: decoder.decode(result.stderr).trim(),
5152
};
5253
}
5354

@@ -62,6 +63,22 @@ function tagCommit(tag) {
6263
return result.exitCode === 0 ? result.stdout : null;
6364
}
6465

66+
function refreshTagFromOrigin(tag) {
67+
const remote = git(['remote', 'get-url', 'origin'], { check: false });
68+
if (remote.exitCode !== 0) {
69+
return;
70+
}
71+
const result = git(['fetch', '--force', '--no-tags', 'origin', `refs/tags/${tag}:refs/tags/${tag}`], {
72+
check: false,
73+
});
74+
if (result.exitCode !== 0 && tagCommit(tag) === null) {
75+
fail(`${tag} does not exist on origin. Run release-please before release publish steps.`);
76+
}
77+
if (result.exitCode !== 0) {
78+
fail(`could not refresh ${tag} from origin before verification${result.stderr ? `: ${result.stderr}` : ''}`);
79+
}
80+
}
81+
6582
async function releasePleaseProduct(product) {
6683
const config = JSON.parse(await fs.readFile(path.join(root, 'release-please-config.json'), 'utf8'));
6784
if (config['include-v-in-tag'] !== true) {
@@ -144,9 +161,10 @@ const { product, target } = parseArgs(Bun.argv.slice(2));
144161
const version = await currentProductVersion(product);
145162
const tag = `${product}-v${version}`;
146163
const targetCommit = commitForRef(target);
164+
refreshTagFromOrigin(tag);
147165
const existing = tagCommit(tag);
148166
if (existing === null) {
149-
fail(`${tag} does not exist. Run release-please before package-native publish steps.`);
167+
fail(`${tag} does not exist. Run release-please before release publish steps.`);
150168
}
151169
if (existing !== targetCommit) {
152170
fail(`${tag} points at ${existing}, not release commit ${targetCommit}`);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bun
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
6+
const decoder = new TextDecoder();
7+
8+
function fail(message) {
9+
console.error(`verify_product_tags.mjs: ${message}`);
10+
process.exit(1);
11+
}
12+
13+
function parseArgs(argv) {
14+
let productsJson = '';
15+
let target = process.env.GITHUB_SHA || 'HEAD';
16+
for (let index = 0; index < argv.length; index += 1) {
17+
const arg = argv[index];
18+
if (arg === '--products-json') {
19+
productsJson = argv[index + 1] ?? '';
20+
index += 1;
21+
continue;
22+
}
23+
if (arg === '--target') {
24+
target = argv[index + 1] ?? '';
25+
index += 1;
26+
continue;
27+
}
28+
fail(`unknown argument: ${arg}`);
29+
}
30+
if (!productsJson || !target) {
31+
fail('usage: tools/release/verify_product_tags.mjs --products-json <json-array> [--target <commitish>]');
32+
}
33+
return { productsJson, target };
34+
}
35+
36+
function parseProducts(productsJson) {
37+
let products;
38+
try {
39+
products = JSON.parse(productsJson);
40+
} catch (error) {
41+
fail(`--products-json must be valid JSON: ${error.message}`);
42+
}
43+
if (!Array.isArray(products) || products.length === 0 || !products.every((product) => typeof product === 'string' && product)) {
44+
fail('--products-json must be a non-empty JSON string array');
45+
}
46+
return [...new Set(products)].sort((left, right) => left.localeCompare(right));
47+
}
48+
49+
function runVerifyProductTag(product, target) {
50+
const result = Bun.spawnSync(['tools/dev/bun.sh', 'tools/release/verify_product_tag.mjs', product, '--target', target], {
51+
cwd: root,
52+
stdout: 'inherit',
53+
stderr: 'pipe',
54+
});
55+
if (result.exitCode !== 0) {
56+
const stderr = decoder.decode(result.stderr).trim();
57+
fail(`${product} release tag is not ready${stderr ? `: ${stderr}` : ''}`);
58+
}
59+
}
60+
61+
const { productsJson, target } = parseArgs(Bun.argv.slice(2));
62+
const products = parseProducts(productsJson);
63+
for (const product of products) {
64+
runVerifyProductTag(product, target);
65+
}
66+
console.log(`verified ${products.length} release product tag(s) at ${target}`);

0 commit comments

Comments
 (0)