Skip to content

Commit 379f9cd

Browse files
claudedgellow
authored andcommitted
feat: implement lockstep versioning for workspaces
Updates createReleaseCommitAndGetSha and syncBranch to handle workspace packages with lockstep versioning: - Detect workspace members from root deno.json - Update all member deno.json files with the same version - Update .pls/versions.json with all package paths at the same version This ensures all packages in a monorepo are released together with the same version number, simplifying dependency management.
1 parent 28ef8e3 commit 379f9cd

File tree

1 file changed

+142
-49
lines changed

1 file changed

+142
-49
lines changed

src/core/pull-request.ts

Lines changed: 142 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ export class ReleasePullRequest {
281281
* This allows the caller to update the branch ref atomically, avoiding
282282
* the race condition where resetting the branch first causes GitHub
283283
* to auto-close the PR (0 commits = closed).
284+
*
285+
* Implements lockstep versioning: all workspace packages get the same version.
284286
*/
285287
private async createReleaseCommitAndGetSha(
286288
bump: VersionBump,
@@ -301,23 +303,38 @@ export class ReleasePullRequest {
301303

302304
// Get current deno.json content
303305
let denoJsonContent: string;
306+
let workspaceMembers: string[] = [];
304307
try {
305308
const file = await this.request<{ content: string }>(
306309
`/repos/${this.owner}/${this.repo}/contents/deno.json?ref=${this.baseBranch}`,
307310
);
308311
denoJsonContent = atob(file.content.replace(/\n/g, ''));
312+
const parsed = JSON.parse(denoJsonContent);
313+
workspaceMembers = parsed.workspace || [];
309314
} catch {
310315
denoJsonContent = '{}';
311316
}
312317

313-
// Update version in deno.json
318+
// Update version in root deno.json
314319
const denoJson = JSON.parse(denoJsonContent);
315320
denoJson.version = bump.to;
316321
const newDenoJson = JSON.stringify(denoJson, null, 2) + '\n';
317322

323+
// Collect tree entries for all files to update
324+
const treeEntries: Array<{ path: string; mode: string; type: string; sha: string }> = [];
325+
326+
// Create blob for root deno.json
327+
const rootDenoBlob = await this.request<{ sha: string }>(
328+
`/repos/${this.owner}/${this.repo}/git/blobs`,
329+
{
330+
method: 'POST',
331+
body: JSON.stringify({ content: newDenoJson, encoding: 'utf-8' }),
332+
},
333+
);
334+
treeEntries.push({ path: 'deno.json', mode: '100644', type: 'blob', sha: rootDenoBlob.sha });
335+
318336
// Get current .pls/versions.json content (or create new)
319337
// Note: Don't preserve SHA - it becomes stale after squash/rebase merge
320-
// SHA is only set from GitHub releases API after merge
321338
let versionsContent: Record<string, string>;
322339
try {
323340
const file = await this.request<{ content: string }>(
@@ -334,53 +351,74 @@ export class ReleasePullRequest {
334351
} catch {
335352
versionsContent = {};
336353
}
354+
355+
// Set root version
337356
versionsContent['.'] = bump.to;
338-
const newVersionsJson = JSON.stringify(versionsContent, null, 2) + '\n';
339357

340-
// Create blobs for both files
341-
const denoBlob = await this.request<{ sha: string }>(
342-
`/repos/${this.owner}/${this.repo}/git/blobs`,
343-
{
344-
method: 'POST',
345-
body: JSON.stringify({
346-
content: newDenoJson,
347-
encoding: 'utf-8',
348-
}),
349-
},
350-
);
358+
// Update workspace member deno.json files (lockstep versioning)
359+
for (const pattern of workspaceMembers) {
360+
// Skip glob patterns - only handle direct paths
361+
if (pattern.includes('*')) continue;
351362

363+
const memberPath = pattern.replace(/^\.\//, '');
364+
365+
try {
366+
const file = await this.request<{ content: string }>(
367+
`/repos/${this.owner}/${this.repo}/contents/${memberPath}/deno.json?ref=${this.baseBranch}`,
368+
);
369+
const memberContent = atob(file.content.replace(/\n/g, ''));
370+
const memberJson = JSON.parse(memberContent);
371+
372+
// Update version to match root (lockstep)
373+
memberJson.version = bump.to;
374+
const newMemberJson = JSON.stringify(memberJson, null, 2) + '\n';
375+
376+
// Create blob for member deno.json
377+
const memberBlob = await this.request<{ sha: string }>(
378+
`/repos/${this.owner}/${this.repo}/git/blobs`,
379+
{
380+
method: 'POST',
381+
body: JSON.stringify({ content: newMemberJson, encoding: 'utf-8' }),
382+
},
383+
);
384+
treeEntries.push({
385+
path: `${memberPath}/deno.json`,
386+
mode: '100644',
387+
type: 'blob',
388+
sha: memberBlob.sha,
389+
});
390+
391+
// Add to versions manifest
392+
versionsContent[memberPath] = bump.to;
393+
} catch {
394+
// Member doesn't have deno.json, skip
395+
}
396+
}
397+
398+
// Create blob for versions.json
399+
const newVersionsJson = JSON.stringify(versionsContent, null, 2) + '\n';
352400
const versionsBlob = await this.request<{ sha: string }>(
353401
`/repos/${this.owner}/${this.repo}/git/blobs`,
354402
{
355403
method: 'POST',
356-
body: JSON.stringify({
357-
content: newVersionsJson,
358-
encoding: 'utf-8',
359-
}),
404+
body: JSON.stringify({ content: newVersionsJson, encoding: 'utf-8' }),
360405
},
361406
);
407+
treeEntries.push({
408+
path: '.pls/versions.json',
409+
mode: '100644',
410+
type: 'blob',
411+
sha: versionsBlob.sha,
412+
});
362413

363-
// Create tree with updated files
414+
// Create tree with all updated files
364415
const tree = await this.request<{ sha: string }>(
365416
`/repos/${this.owner}/${this.repo}/git/trees`,
366417
{
367418
method: 'POST',
368419
body: JSON.stringify({
369420
base_tree: baseCommit.tree.sha,
370-
tree: [
371-
{
372-
path: 'deno.json',
373-
mode: '100644',
374-
type: 'blob',
375-
sha: denoBlob.sha,
376-
},
377-
{
378-
path: '.pls/versions.json',
379-
mode: '100644',
380-
type: 'blob',
381-
sha: versionsBlob.sha,
382-
},
383-
],
421+
tree: treeEntries,
384422
}),
385423
},
386424
);
@@ -494,6 +532,8 @@ ${optionsBlock}
494532
/**
495533
* Sync PR branch with the selected version.
496534
* Resets branch to base, creates fresh commit with new version, force pushes.
535+
*
536+
* Implements lockstep versioning: all workspace packages get the same version.
497537
*/
498538
async syncBranch(
499539
prNumber: number,
@@ -516,22 +556,38 @@ ${optionsBlock}
516556
`/repos/${this.owner}/${this.repo}/git/commits/${baseSha}`,
517557
);
518558

519-
// Get current deno.json content
559+
// Get current deno.json content and workspace members
520560
let denoJsonContent: string;
561+
let workspaceMembers: string[] = [];
521562
try {
522563
const file = await this.request<{ content: string }>(
523564
`/repos/${this.owner}/${this.repo}/contents/deno.json?ref=${baseBranch}`,
524565
);
525566
denoJsonContent = atob(file.content.replace(/\n/g, ''));
567+
const parsed = JSON.parse(denoJsonContent);
568+
workspaceMembers = parsed.workspace || [];
526569
} catch {
527570
denoJsonContent = '{}';
528571
}
529572

530-
// Update version in deno.json
573+
// Update version in root deno.json
531574
const denoJson = JSON.parse(denoJsonContent);
532575
denoJson.version = selectedVersion;
533576
const newDenoJson = JSON.stringify(denoJson, null, 2) + '\n';
534577

578+
// Collect tree entries for all files to update
579+
const treeEntries: Array<{ path: string; mode: string; type: string; sha: string }> = [];
580+
581+
// Create blob for root deno.json
582+
const rootDenoBlob = await this.request<{ sha: string }>(
583+
`/repos/${this.owner}/${this.repo}/git/blobs`,
584+
{
585+
method: 'POST',
586+
body: JSON.stringify({ content: newDenoJson, encoding: 'utf-8' }),
587+
},
588+
);
589+
treeEntries.push({ path: 'deno.json', mode: '100644', type: 'blob', sha: rootDenoBlob.sha });
590+
535591
// Get/create .pls/versions.json content
536592
// Note: Don't preserve SHA - it becomes stale after squash/rebase merge
537593
let versionsContent: Record<string, string>;
@@ -550,37 +606,74 @@ ${optionsBlock}
550606
} catch {
551607
versionsContent = {};
552608
}
609+
610+
// Set root version
553611
versionsContent['.'] = selectedVersion;
554-
const newVersionsJson = JSON.stringify(versionsContent, null, 2) + '\n';
555612

556-
// Create blobs
557-
const denoBlob = await this.request<{ sha: string }>(
558-
`/repos/${this.owner}/${this.repo}/git/blobs`,
559-
{
560-
method: 'POST',
561-
body: JSON.stringify({ content: newDenoJson, encoding: 'utf-8' }),
562-
},
563-
);
613+
// Update workspace member deno.json files (lockstep versioning)
614+
for (const pattern of workspaceMembers) {
615+
// Skip glob patterns - only handle direct paths
616+
if (pattern.includes('*')) continue;
617+
618+
const memberPath = pattern.replace(/^\.\//, '');
564619

620+
try {
621+
const file = await this.request<{ content: string }>(
622+
`/repos/${this.owner}/${this.repo}/contents/${memberPath}/deno.json?ref=${baseBranch}`,
623+
);
624+
const memberContent = atob(file.content.replace(/\n/g, ''));
625+
const memberJson = JSON.parse(memberContent);
626+
627+
// Update version to match root (lockstep)
628+
memberJson.version = selectedVersion;
629+
const newMemberJson = JSON.stringify(memberJson, null, 2) + '\n';
630+
631+
// Create blob for member deno.json
632+
const memberBlob = await this.request<{ sha: string }>(
633+
`/repos/${this.owner}/${this.repo}/git/blobs`,
634+
{
635+
method: 'POST',
636+
body: JSON.stringify({ content: newMemberJson, encoding: 'utf-8' }),
637+
},
638+
);
639+
treeEntries.push({
640+
path: `${memberPath}/deno.json`,
641+
mode: '100644',
642+
type: 'blob',
643+
sha: memberBlob.sha,
644+
});
645+
646+
// Add to versions manifest
647+
versionsContent[memberPath] = selectedVersion;
648+
} catch {
649+
// Member doesn't have deno.json, skip
650+
}
651+
}
652+
653+
// Create blob for versions.json
654+
const newVersionsJson = JSON.stringify(versionsContent, null, 2) + '\n';
565655
const versionsBlob = await this.request<{ sha: string }>(
566656
`/repos/${this.owner}/${this.repo}/git/blobs`,
567657
{
568658
method: 'POST',
569659
body: JSON.stringify({ content: newVersionsJson, encoding: 'utf-8' }),
570660
},
571661
);
662+
treeEntries.push({
663+
path: '.pls/versions.json',
664+
mode: '100644',
665+
type: 'blob',
666+
sha: versionsBlob.sha,
667+
});
572668

573-
// Create tree
669+
// Create tree with all updated files
574670
const tree = await this.request<{ sha: string }>(
575671
`/repos/${this.owner}/${this.repo}/git/trees`,
576672
{
577673
method: 'POST',
578674
body: JSON.stringify({
579675
base_tree: baseCommit.tree.sha,
580-
tree: [
581-
{ path: 'deno.json', mode: '100644', type: 'blob', sha: denoBlob.sha },
582-
{ path: '.pls/versions.json', mode: '100644', type: 'blob', sha: versionsBlob.sha },
583-
],
676+
tree: treeEntries,
584677
}),
585678
},
586679
);

0 commit comments

Comments
 (0)