Skip to content

Commit 6d21bbe

Browse files
committed
fix(hidi): bootstrap version labels and isolate legacy tag history
Create only the two verified Hidi lifecycle labels, retain tag-free version cycles after baseline advances, and retire the duplicate unfiltered OData tag writer and its GitHub-release-only consumer. Keep official publishing gates unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5aef9099-c268-492f-a58a-b591c916f1bc
1 parent ec73824 commit 6d21bbe

5 files changed

Lines changed: 195 additions & 85 deletions

File tree

‎.github/release-please/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"private": true,
44
"scripts": {
55
"test": "node --test run.test.cjs",
6-
"coverage": "cd ../.. && node --test --experimental-test-coverage --test-coverage-include=.github/release-please/run.cjs --test-coverage-lines=80 --test-coverage-functions=80 --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=artifacts/release-please/lcov.info .github/release-please/run.test.cjs"
6+
"coverage": "node -e \"require('node:fs').mkdirSync('../../artifacts/release-please', {recursive: true})\" && cd ../.. && node --test --experimental-test-coverage --test-coverage-include=.github/release-please/run.cjs --test-coverage-lines=80 --test-coverage-functions=80 --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=artifacts/release-please/lcov.info .github/release-please/run.test.cjs"
77
},
88
"dependencies": {
99
"@actions/core": "1.11.1",

‎.github/release-please/run.cjs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,41 @@ const HIDI_PENDING = 'autorelease: hidi-pending';
88
const HIDI_VERSIONED = 'autorelease: hidi-versioned';
99
const HIDI_FLOORS = {'main': '3.10.2', 'support/v2': '2.12.2'};
1010

11+
async function ensureHidiLabels(token, owner, repo, request = fetch) {
12+
const api = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
13+
const url = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`;
14+
const headers = {
15+
Authorization: `Bearer ${token}`,
16+
Accept: 'application/vnd.github+json',
17+
'X-GitHub-Api-Version': '2022-11-28'
18+
};
19+
for (const label of [
20+
{name: HIDI_PENDING, color: 'fbca04', description: 'Pending independent Hidi version pull request'},
21+
{name: HIDI_VERSIONED, color: '0e8a16', description: 'Merged Hidi version pull request; no production release'}
22+
]) {
23+
const labelUrl = `${url}/${encodeURIComponent(label.name)}`;
24+
let response = await request(labelUrl, {headers});
25+
let expectedStatus = 200;
26+
if (response.status === 404) {
27+
response = await request(url, {
28+
method: 'POST', headers: {...headers, 'Content-Type': 'application/json'},
29+
body: JSON.stringify(label)
30+
});
31+
if (response.status === 422) {
32+
// Another branch job may have created the same label concurrently.
33+
response = await request(labelUrl, {headers});
34+
} else if (response.status !== 201) {
35+
throw new Error(`Cannot create Hidi lifecycle label '${label.name}': HTTP ${response.status}`);
36+
} else {
37+
expectedStatus = 201;
38+
}
39+
}
40+
if (response.status !== expectedStatus || (await response.json()).name !== label.name) {
41+
throw new Error(`Cannot verify Hidi lifecycle label '${label.name}': HTTP ${response.status}`);
42+
}
43+
}
44+
}
45+
1146
class ExactPathExclusions extends ManifestPlugin {
1247
async preconfigure(strategies, commitsByPath) {
1348
for (const [path, config] of Object.entries(this.repositoryConfig)) {
@@ -144,6 +179,7 @@ async function main() {
144179
const branch = process.env.GITHUB_REF_NAME;
145180
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
146181
if (!token || !branch || !owner || !repo) throw new Error('Missing release automation environment');
182+
if (process.argv[2] === 'hidi') await ensureHidiLabels(token, owner, repo);
147183
const github = await GitHub.create({owner, repo, token, defaultBranch: branch});
148184
const {releases, prs} = await runReleasePlease(github, branch, process.argv[2]);
149185
core.setOutput('releases_created', releases.length > 0);
@@ -161,7 +197,7 @@ async function main() {
161197
}
162198
}
163199

164-
module.exports = {ExactPathExclusions, prepareHidiVersionPrs, runReleasePlease, main};
200+
module.exports = {ExactPathExclusions, prepareHidiVersionPrs, runReleasePlease, ensureHidiLabels, main};
165201
if (require.main === module) {
166202
main().catch(error => require('@actions/core').setFailed(error.message));
167203
}

‎.github/release-please/run.test.cjs‎

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ const fs = require('node:fs');
33
const path = require('node:path');
44
const {test} = require('node:test');
55
const {GitHub, Manifest, setLogger} = require('release-please');
6-
const {runReleasePlease, main, ExactPathExclusions} = require('./run.cjs');
6+
const {Version} = require('release-please/build/src/version');
7+
const {PullRequestBody} = require('release-please/build/src/util/pull-request-body');
8+
const {runReleasePlease, main, ExactPathExclusions, ensureHidiLabels} = require('./run.cjs');
79

810
setLogger({debug() {}, info() {}, warn() {}, error() {}});
911
const root = path.resolve(__dirname, '..', '..');
@@ -20,7 +22,7 @@ const inputPaths = [
2022
];
2123

2224
class MemoryGitHub {
23-
constructor() {
25+
constructor(hidiVersion) {
2426
this.repository = {owner: 'microsoft', repo: 'OpenAPI.NET.OData', defaultBranch: branch};
2527
this.files = Object.fromEntries(inputPaths.map(file => [file, fs.readFileSync(path.join(root, file), 'utf8')]));
2628
this.commits = ['hidi-release-please-config.json', 'release-please-config.json'].map(file => ({
@@ -31,6 +33,24 @@ class MemoryGitHub {
3133
this.tags = [];
3234
this.labelWrites = [];
3335
this.nextPr = 1;
36+
if (hidiVersion) {
37+
this.files[hidiManifest] = JSON.stringify({'.': hidiVersion});
38+
this.files[hidiProject] = this.files[hidiProject].replace(/<Version>.*?<\/Version>/, `<Version>${hidiVersion}</Version>`);
39+
}
40+
const current = JSON.parse(this.files[hidiManifest])['.'];
41+
const floor = branch === 'main' ? '3.10.2' : '2.12.2';
42+
if (current !== floor) {
43+
const checkpoint = {
44+
number: 0, state: 'MERGED', sha: 'initial-hidi-checkpoint',
45+
headBranchName: `release-please--branches--${branch}--components--hidi`,
46+
baseBranchName: branch,
47+
title: `chore(${branch}): release hidi ${current}`,
48+
body: new PullRequestBody([{component: 'hidi', version: Version.parse(current), notes: 'Previously merged version'}], {useComponents: true}).toString(),
49+
labels: ['autorelease: hidi-versioned'], files: [hidiManifest, hidiProject]
50+
};
51+
this.prs.push(checkpoint);
52+
this.commits.unshift({sha: checkpoint.sha, message: checkpoint.title, files: checkpoint.files, pullRequest: checkpoint});
53+
}
3454
}
3555
async getFileJson(file) { return JSON.parse(this.files[file]); }
3656
async getFileContentsOnBranch(file) {
@@ -165,6 +185,20 @@ test('mixed changes affect both components; excluded-only and empty changes affe
165185
assert.equal((await plans(github, 'hidi')).length, 0);
166186
});
167187

188+
test('an OData fix after Hidi features keeps the OData patch and changelog independent', async () => {
189+
const github = new MemoryGitHub();
190+
const current = JSON.parse(github.files[odataManifest])['.'];
191+
github.change(['Dockerfile'], 'feat(hidi): independent feature before root fix');
192+
github.change(['Directory.Build.props'], 'fix(odata): library fix after Hidi');
193+
const [pr] = await plans(github, 'odata');
194+
assert.equal(pr.version.toString(), current.replace(/\d+$/, patch => Number(patch) + 1));
195+
assert.ok(pr.body.toString().includes('library fix after Hidi'));
196+
assert.ok(!pr.body.toString().includes('independent feature'));
197+
const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'ci-cd.yml'), 'utf8');
198+
assert.ok(!workflow.includes('mathieudutour/github-tag-action'));
199+
assert.ok(!workflow.includes('softprops/action-gh-release'));
200+
});
201+
168202
test('actual Hidi patch output changes only its version, manifest, changelog and install examples', async () => {
169203
const github = new MemoryGitHub();
170204
const baseline = github.files[hidiProject];
@@ -182,7 +216,8 @@ test('actual Hidi patch output changes only its version, manifest, changelog and
182216
assert.match(output[hidiChangelog], new RegExp(`hidi-v${oldVersion}\\.\\.\\.hidi-v${next}`));
183217
assert.equal(JSON.parse(output[hidiManifest])['.'], next);
184218
assert.ok(output[hidiReadme].includes(`--version ${next}`));
185-
assert.ok(output[hidiReadme].includes(`YamlReader\` ${branch === 'main' ? '3.10.2' : '2.12.2'}`));
219+
const unannotatedLines = text => text.split(/\r?\n/).filter(line => !line.includes('x-release-please-version'));
220+
assert.deepEqual(unannotatedLines(output[hidiReadme]), unannotatedLines(github.files[hidiReadme]));
186221
});
187222

188223
test('actual OData output and componentless tags retain existing behavior', async () => {
@@ -212,10 +247,32 @@ test('Hidi feature release increments minor; breaking changes fail before writin
212247
github.commits.shift();
213248
github.change(['src/Microsoft.OpenApi.Hidi/Program.cs'], 'feat(hidi)!: incompatible feature');
214249
await assert.rejects(runReleasePlease(github, branch, 'hidi'), /outside the branch major/);
215-
assert.equal(github.prs.length, 0);
250+
assert.equal(github.prs.filter(pr => pr.state === 'OPEN').length, 0);
216251
assert.equal(github.releases.length, 0);
217252
});
218253

254+
test('pure Hidi fix and feature release plans retain the official pipeline tag convention', async () => {
255+
const pipeline = fs.readFileSync(path.join(root, '.azure-pipelines', 'hidi-release.yml'), 'utf8');
256+
const floor = branch === 'main' ? '3.10.2' : '2.12.2';
257+
assert.ok(pipeline.includes(`[version]'${floor}'`));
258+
assert.ok(pipeline.includes(`hidiPublishingEnabled: 'false'`));
259+
for (const type of ['fix', 'feat']) {
260+
const github = new MemoryGitHub();
261+
const [major, minor, patch] = JSON.parse(github.files[hidiManifest])['.'].split('.').map(Number);
262+
const next = type === 'fix' ? `${major}.${minor}.${patch + 1}` : `${major}.${minor + 1}.0`;
263+
assert.ok(pipeline.includes(`- hidi-v${major}.*`));
264+
github.change(['Dockerfile'], `${type}(hidi): tag plan`);
265+
const {prs} = await runReleasePlease(github, branch, 'hidi');
266+
github.merge(prs[0]);
267+
const manifest = await Manifest.fromManifest(github, branch, 'hidi-release-please-config.json', hidiManifest);
268+
const [release] = await manifest.buildReleases();
269+
assert.equal(release.tag.toString(), `hidi-v${next}`);
270+
assert.match(release.notes, new RegExp(`hidi-v${major}\\.${minor}\\.${patch}\\.\\.\\.hidi-v${next}`));
271+
assert.deepEqual(github.tags, []);
272+
assert.deepEqual(github.releases, []);
273+
}
274+
});
275+
219276
test('two tag-free Hidi version cycles do not replay commits or block OData', async () => {
220277
const github = new MemoryGitHub();
221278
const baseline = JSON.parse(github.files[hidiManifest])['.'];
@@ -243,14 +300,28 @@ test('two tag-free Hidi version cycles do not replay commits or block OData', as
243300
assert.deepEqual(github.releases, []);
244301
});
245302

303+
test('version automation and its fixtures keep working after the repository manifest advances', async () => {
304+
const current = JSON.parse(fs.readFileSync(path.join(root, hidiManifest), 'utf8'))['.'];
305+
const advanced = current.replace(/\d+$/, patch => Number(patch) + 1);
306+
const next = current.replace(/\d+$/, patch => Number(patch) + 2);
307+
const github = new MemoryGitHub(advanced);
308+
github.change(['Dockerfile'], 'fix(hidi): after an existing version checkpoint');
309+
const {prs, releases} = await runReleasePlease(github, branch, 'hidi');
310+
assert.equal(JSON.parse(prs[0].output[hidiManifest])['.'], next);
311+
assert.deepEqual(releases, []);
312+
github.merge(prs[0]);
313+
assert.equal((await runReleasePlease(github, branch, 'hidi')).prs.length, 0);
314+
assert.deepEqual(github.tags, []);
315+
});
316+
246317
test('open Hidi PR updates with additional commits instead of opening a duplicate', async () => {
247318
const github = new MemoryGitHub();
248319
github.change(['Dockerfile'], 'fix(hidi): first fix');
249320
const first = await runReleasePlease(github, branch, 'hidi');
250321
github.change(['install-tool.ps1'], 'fix(hidi): additional fix');
251322
const updated = await runReleasePlease(github, branch, 'hidi');
252323
assert.equal(updated.prs[0].number, first.prs[0].number);
253-
assert.equal(github.prs.length, 1);
324+
assert.equal(github.prs.filter(pr => pr.state === 'OPEN').length, 1);
254325
assert.ok(updated.prs[0].body.includes('additional fix'));
255326
});
256327

@@ -313,13 +384,85 @@ test('Hidi ignores pending OData PRs and interrupted label transitions are retry
313384
assert.deepEqual(rootPr.labels, ['autorelease: pending']);
314385
});
315386

387+
test('Hidi lifecycle labels are created using the authenticated GitHub REST API only when missing', async () => {
388+
const calls = [];
389+
const labels = new Set();
390+
const request = async (url, options) => {
391+
assert.equal(options.headers.Authorization, 'Bearer offline-test-token');
392+
assert.equal(options.headers['X-GitHub-Api-Version'], '2022-11-28');
393+
calls.push([url, options.method || 'GET']);
394+
if (options.method === 'POST') {
395+
const label = JSON.parse(options.body);
396+
assert.ok(['autorelease: hidi-pending', 'autorelease: hidi-versioned'].includes(label.name));
397+
labels.add(label.name);
398+
return Response.json(label, {status: 201});
399+
}
400+
const name = decodeURIComponent(url.split('/').at(-1));
401+
return labels.has(name) ? Response.json({name}) : Response.json({}, {status: 404});
402+
};
403+
await ensureHidiLabels('offline-test-token', 'microsoft', 'OpenAPI.NET.OData', request);
404+
assert.deepEqual(calls.map(call => call[1]), ['GET', 'POST', 'GET', 'POST']);
405+
assert.ok(calls[0][0].endsWith('/labels/autorelease%3A%20hidi-pending'));
406+
calls.length = 0;
407+
await ensureHidiLabels('offline-test-token', 'microsoft', 'OpenAPI.NET.OData', request);
408+
assert.deepEqual(calls.map(call => call[1]), ['GET', 'GET']);
409+
});
410+
411+
test('Hidi label bootstrap fails explicitly on permissions, network errors or invalid responses', async t => {
412+
for (const status of [401, 403, 500]) {
413+
await t.test(`read HTTP ${status}`, async () => {
414+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
415+
async () => Response.json({}, {status})), new RegExp(`HTTP ${status}`));
416+
});
417+
}
418+
await t.test('creation permissions', async () => {
419+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
420+
async (url, options) => Response.json({}, {status: options.method === 'POST' ? 403 : 404})),
421+
/Cannot create.*HTTP 403/);
422+
});
423+
await t.test('network error', async () => {
424+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
425+
async () => { throw new Error('offline connection failed'); }), /offline connection failed/);
426+
});
427+
await t.test('unexpected label', async () => {
428+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
429+
async () => Response.json({name: 'autorelease: pending'})), /Cannot verify/);
430+
});
431+
await t.test('invalid JSON', async () => {
432+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
433+
async () => new Response('invalid JSON')), SyntaxError);
434+
});
435+
});
436+
437+
test('concurrent Hidi label creation is accepted only after verifying the exact existing label', async () => {
438+
const existing = new Set();
439+
await ensureHidiLabels('token', 'owner', 'repo', async (url, options) => {
440+
if (options.method === 'POST') {
441+
existing.add(JSON.parse(options.body).name);
442+
return Response.json({}, {status: 422});
443+
}
444+
const name = decodeURIComponent(url.split('/').at(-1));
445+
return existing.has(name) ? Response.json({name}) : Response.json({}, {status: 404});
446+
});
447+
await assert.rejects(ensureHidiLabels('token', 'owner', 'repo',
448+
async (url, options) => Response.json({}, {status: options.method === 'POST' ? 422 : 404})),
449+
/Cannot verify.*HTTP 404/);
450+
});
451+
316452
test('CLI wiring emits real library PR/release outputs and uses the app token', async t => {
317453
const github = new MemoryGitHub();
318454
const core = require('@actions/core');
319455
const saved = {...process.env};
320456
const argv = process.argv;
321457
t.after(() => { process.env = saved; process.argv = argv; });
322458
Object.assign(process.env, {RELEASE_PLEASE_TOKEN: 'offline-test-token', GITHUB_REF_NAME: branch, GITHUB_REPOSITORY: 'microsoft/OpenAPI.NET.OData'});
459+
let labelReads = 0;
460+
t.mock.method(globalThis, 'fetch', async (url, options) => {
461+
labelReads++;
462+
assert.equal(options.headers.Authorization, 'Bearer offline-test-token');
463+
assert.equal(options.method, undefined);
464+
return Response.json({name: decodeURIComponent(url.split('/').at(-1))});
465+
});
323466
t.mock.method(GitHub, 'create', async options => {
324467
assert.equal(options.token, 'offline-test-token');
325468
assert.equal(options.defaultBranch, branch);
@@ -343,6 +486,7 @@ test('CLI wiring emits real library PR/release outputs and uses the app token',
343486
assert.equal(outputs.release_created, true);
344487
assert.ok(outputs.tag_name.startsWith('v'));
345488
assert.ok(outputs.html_url);
489+
assert.equal(labelReads, 2);
346490
delete process.env.RELEASE_PLEASE_TOKEN;
347491
await assert.rejects(main(), /Missing release automation environment/);
348492
});

0 commit comments

Comments
 (0)