@@ -3,7 +3,9 @@ const fs = require('node:fs');
33const path = require ( 'node:path' ) ;
44const { test} = require ( 'node:test' ) ;
55const { 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
810setLogger ( { debug ( ) { } , info ( ) { } , warn ( ) { } , error ( ) { } } ) ;
911const root = path . resolve ( __dirname , '..' , '..' ) ;
@@ -20,7 +22,7 @@ const inputPaths = [
2022] ;
2123
2224class 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 ( / < V e r s i o n > .* ?< \/ V e r s i o n > / , `<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+
168202test ( '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
188223test ( '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' ) , / o u t s i d e t h e b r a n c h m a j o r / ) ;
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+
219276test ( '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+
246317test ( '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+ / C a n n o t c r e a t e .* H T T P 4 0 3 / ) ;
422+ } ) ;
423+ await t . test ( 'network error' , async ( ) => {
424+ await assert . rejects ( ensureHidiLabels ( 'token' , 'owner' , 'repo' ,
425+ async ( ) => { throw new Error ( 'offline connection failed' ) ; } ) , / o f f l i n e c o n n e c t i o n f a i l e d / ) ;
426+ } ) ;
427+ await t . test ( 'unexpected label' , async ( ) => {
428+ await assert . rejects ( ensureHidiLabels ( 'token' , 'owner' , 'repo' ,
429+ async ( ) => Response . json ( { name : 'autorelease: pending' } ) ) , / C a n n o t v e r i f y / ) ;
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+ / C a n n o t v e r i f y .* H T T P 4 0 4 / ) ;
450+ } ) ;
451+
316452test ( '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 ( ) , / M i s s i n g r e l e a s e a u t o m a t i o n e n v i r o n m e n t / ) ;
348492} ) ;
0 commit comments