versionary 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action/index.js +45 -18
- package/dist/cli/index.js +211 -24
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +32 -0
- package/dist/index.d.ts +2 -2
- package/dist/release/artifact-rules.js +27 -28
- package/dist/release/changelog.js +1 -2
- package/dist/release/cohorts.d.ts +6 -0
- package/dist/release/cohorts.js +125 -0
- package/dist/release/plan.js +49 -45
- package/dist/release/pr.d.ts +69 -0
- package/dist/release/pr.js +555 -22
- package/dist/release/release.d.ts +12 -0
- package/dist/release/release.js +26 -7
- package/dist/release/state.d.ts +16 -1
- package/dist/release/state.js +182 -9
- package/dist/release/targets.d.ts +21 -0
- package/dist/release/targets.js +85 -0
- package/dist/scm/github-plugin.js +44 -0
- package/dist/scm/types.d.ts +11 -0
- package/dist/strategy/cmake.d.ts +2 -0
- package/dist/strategy/cmake.js +283 -0
- package/dist/strategy/resolve.js +2 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/plugins.d.ts +3 -1
- package/package.json +3 -1
|
@@ -15,6 +15,12 @@ export type RunReleaseResult = {
|
|
|
15
15
|
tag: string;
|
|
16
16
|
version: string;
|
|
17
17
|
}[];
|
|
18
|
+
releaseTargets: {
|
|
19
|
+
path: string;
|
|
20
|
+
tag: string;
|
|
21
|
+
version: string;
|
|
22
|
+
dependencies: string[];
|
|
23
|
+
}[];
|
|
18
24
|
} | {
|
|
19
25
|
action: "release-published";
|
|
20
26
|
message: string;
|
|
@@ -24,6 +30,12 @@ export type RunReleaseResult = {
|
|
|
24
30
|
tagStatus: "created" | "exists";
|
|
25
31
|
metadataStatus: "created" | "exists";
|
|
26
32
|
}[];
|
|
33
|
+
releaseTargets: {
|
|
34
|
+
path: string;
|
|
35
|
+
tag: string;
|
|
36
|
+
version: string;
|
|
37
|
+
dependencies: string[];
|
|
38
|
+
}[];
|
|
27
39
|
};
|
|
28
40
|
export interface RunReleaseOptions {
|
|
29
41
|
logger?: VersionaryPluginContext["logger"];
|
package/dist/release/release.js
CHANGED
|
@@ -8,7 +8,8 @@ import { resolveVersionStrategy } from "../strategy/resolve.js";
|
|
|
8
8
|
import { getChangelogDefaults } from "./plan.js";
|
|
9
9
|
import { isReleaseCommitMessage } from "./pr.js";
|
|
10
10
|
import { executeIdempotentReleaseTarget } from "./recovery.js";
|
|
11
|
-
import { readPendingReleaseTargets } from "./state.js";
|
|
11
|
+
import { hasReleaseStateChangeAtHead, readPendingReleaseTargets, readRecordedPendingReleaseTargets, } from "./state.js";
|
|
12
|
+
import { releaseTargetHandoff } from "./targets.js";
|
|
12
13
|
function escapeRegExp(input) {
|
|
13
14
|
return input.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
14
15
|
}
|
|
@@ -132,7 +133,8 @@ export async function runRelease(cwd = process.cwd()) {
|
|
|
132
133
|
}
|
|
133
134
|
export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
134
135
|
const commitMessage = getHeadCommitMessage(cwd);
|
|
135
|
-
if (!isReleaseCommitMessage(commitMessage)
|
|
136
|
+
if (!isReleaseCommitMessage(commitMessage) &&
|
|
137
|
+
!hasReleaseStateChangeAtHead(cwd)) {
|
|
136
138
|
return {
|
|
137
139
|
action: "release-skipped",
|
|
138
140
|
reason: "No release commit context detected; skipping release stage.",
|
|
@@ -145,18 +147,33 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
|
145
147
|
defaultChangelogFormat: strategy.getDefaultChangelogFormat?.(),
|
|
146
148
|
});
|
|
147
149
|
const referenceCommentMode = loaded.config["release-reference-comments"] ?? "off";
|
|
148
|
-
const version = strategy.readVersion(cwd, loaded.config);
|
|
149
|
-
const defaultTag = `v${version}`;
|
|
150
150
|
const releaseTargets = readPendingReleaseTargets(cwd);
|
|
151
|
-
|
|
151
|
+
if (loaded.config["separate-release-prs"] && releaseTargets.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
action: "release-skipped",
|
|
154
|
+
reason: "No untagged package release targets found; skipping release stage.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// Recorded-but-fully-tagged targets mean this release already published, so
|
|
158
|
+
// replaying them keeps a re-run idempotent (and still repairs missing release
|
|
159
|
+
// metadata). Only a repo that never recorded any target may fall back to
|
|
160
|
+
// tagging the root package.
|
|
161
|
+
const recordedTargets = releaseTargets.length > 0
|
|
152
162
|
? releaseTargets
|
|
163
|
+
: readRecordedPendingReleaseTargets(cwd);
|
|
164
|
+
const version = recordedTargets.length === 0
|
|
165
|
+
? strategy.readVersion(cwd, loaded.config)
|
|
166
|
+
: undefined;
|
|
167
|
+
const targets = recordedTargets.length > 0
|
|
168
|
+
? recordedTargets
|
|
153
169
|
: [
|
|
154
170
|
{
|
|
155
171
|
path: ".",
|
|
156
|
-
version,
|
|
157
|
-
tag:
|
|
172
|
+
version: version,
|
|
173
|
+
tag: `v${version}`,
|
|
158
174
|
},
|
|
159
175
|
];
|
|
176
|
+
const releaseTargetPlan = releaseTargetHandoff(targets, options.logger);
|
|
160
177
|
if (options["dry-run"]) {
|
|
161
178
|
const targetList = targets.map((target) => `${target.tag} (${target.version})`);
|
|
162
179
|
return {
|
|
@@ -165,6 +182,7 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
|
165
182
|
tag: target.tag,
|
|
166
183
|
version: target.version,
|
|
167
184
|
})),
|
|
185
|
+
releaseTargets: releaseTargetPlan,
|
|
168
186
|
message: `Dry run: would publish releases ${targetList.join(", ")}`,
|
|
169
187
|
};
|
|
170
188
|
}
|
|
@@ -221,6 +239,7 @@ export async function runReleaseDetailed(cwd = process.cwd(), options = {}) {
|
|
|
221
239
|
return {
|
|
222
240
|
action: "release-published",
|
|
223
241
|
releases,
|
|
242
|
+
releaseTargets: releaseTargetPlan,
|
|
224
243
|
message: `Published releases ${published.join(", ")}`,
|
|
225
244
|
};
|
|
226
245
|
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -2,9 +2,24 @@ export interface ReleaseTargetState {
|
|
|
2
2
|
path: string;
|
|
3
3
|
version: string;
|
|
4
4
|
tag: string;
|
|
5
|
+
dependencies?: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface PendingReleaseCohort {
|
|
8
|
+
branch: string;
|
|
9
|
+
targets: ReleaseTargetState[];
|
|
5
10
|
}
|
|
6
11
|
export declare function getBaselineStatePath(cwd: string): string;
|
|
12
|
+
export declare function getPackageStateDirectory(cwd: string): string;
|
|
7
13
|
export declare function readBaselineSha(cwd?: string): string | null;
|
|
8
14
|
export declare function readReleaseTargets(cwd?: string): ReleaseTargetState[];
|
|
9
15
|
export declare function readPendingReleaseTargets(cwd?: string): ReleaseTargetState[];
|
|
10
|
-
export declare function
|
|
16
|
+
export declare function readRecordedPendingReleaseTargets(cwd?: string): ReleaseTargetState[];
|
|
17
|
+
export declare function readPendingReleaseCohorts(cwd?: string): PendingReleaseCohort[];
|
|
18
|
+
/**
|
|
19
|
+
* Pending targets remain in the manifest after publishing, so the tag set is
|
|
20
|
+
* the durable evidence that the pending release actually completed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasFullyUntaggedPendingRelease(cwd?: string): boolean;
|
|
23
|
+
export declare function writePackageReleaseState(cwd: string, baselineSha: string, releaseTargets: ReleaseTargetState[], branch: string): string[];
|
|
24
|
+
export declare function hasReleaseStateChangeAtHead(cwd?: string): boolean;
|
|
25
|
+
export declare function writeBaselineSha(cwd?: string, sha?: string, releaseTargets?: ReleaseTargetState[]): string[];
|
package/dist/release/state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { loadConfig } from "../config/load-config.js";
|
|
@@ -41,6 +42,11 @@ function validateReleaseTargets(value, key, filePath) {
|
|
|
41
42
|
typeof record.tag !== "string") {
|
|
42
43
|
throw new Error(`Invalid release manifest at ${filePath}: ${key} must contain string path, version, and tag.`);
|
|
43
44
|
}
|
|
45
|
+
if (record.dependencies !== undefined &&
|
|
46
|
+
(!Array.isArray(record.dependencies) ||
|
|
47
|
+
!record.dependencies.every((dependency) => typeof dependency === "string"))) {
|
|
48
|
+
throw new Error(`Invalid release manifest at ${filePath}: ${key} dependencies must be an array of package-path strings.`);
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
export function getBaselineStatePath(cwd) {
|
|
@@ -51,6 +57,57 @@ export function getBaselineStatePath(cwd) {
|
|
|
51
57
|
}
|
|
52
58
|
return path.join(cwd, ".versionary-manifest.json");
|
|
53
59
|
}
|
|
60
|
+
export function getPackageStateDirectory(cwd) {
|
|
61
|
+
return `${getBaselineStatePath(cwd)}.d`;
|
|
62
|
+
}
|
|
63
|
+
function packageStateFileName(packagePath) {
|
|
64
|
+
const slug = packagePath === "."
|
|
65
|
+
? "root"
|
|
66
|
+
: packagePath
|
|
67
|
+
.replaceAll("\\", "/")
|
|
68
|
+
.replace(/[^A-Za-z0-9._-]+/gu, "-")
|
|
69
|
+
.replace(/^-+|-+$/gu, "") || "package";
|
|
70
|
+
const digest = createHash("sha256")
|
|
71
|
+
.update(packagePath)
|
|
72
|
+
.digest("hex")
|
|
73
|
+
.slice(0, 12);
|
|
74
|
+
return `${slug}-${digest}.json`;
|
|
75
|
+
}
|
|
76
|
+
function parsePackageStateFile(raw, filePath) {
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
79
|
+
throw new Error(`Invalid package release state at ${filePath}: expected an object.`);
|
|
80
|
+
}
|
|
81
|
+
const state = parsed;
|
|
82
|
+
if (state[MANIFEST_VERSION_KEY] !== 1) {
|
|
83
|
+
throw new Error(`Unsupported ${MANIFEST_VERSION_KEY} in ${filePath}: ${String(state[MANIFEST_VERSION_KEY])}.`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof state.path !== "string" ||
|
|
86
|
+
typeof state[BASELINE_SHA_KEY] !== "string" ||
|
|
87
|
+
typeof state["release-branch"] !== "string") {
|
|
88
|
+
throw new Error(`Invalid package release state at ${filePath}: path, baseline-sha, and release-branch must be strings.`);
|
|
89
|
+
}
|
|
90
|
+
validateReleaseTargets([state["release-target"]], "release-target", filePath);
|
|
91
|
+
const target = state["release-target"];
|
|
92
|
+
if (target.path !== state.path) {
|
|
93
|
+
throw new Error(`Invalid package release state at ${filePath}: release target path must match state path.`);
|
|
94
|
+
}
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
function readPackageStates(cwd) {
|
|
98
|
+
const directory = getPackageStateDirectory(cwd);
|
|
99
|
+
if (!fs.existsSync(directory)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return fs
|
|
103
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
104
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
105
|
+
.map((entry) => {
|
|
106
|
+
const filePath = path.join(directory, entry.name);
|
|
107
|
+
return parsePackageStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
108
|
+
})
|
|
109
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
110
|
+
}
|
|
54
111
|
export function readBaselineSha(cwd = process.cwd()) {
|
|
55
112
|
const filePath = getBaselineStatePath(cwd);
|
|
56
113
|
if (!fs.existsSync(filePath)) {
|
|
@@ -64,22 +121,132 @@ export function readBaselineSha(cwd = process.cwd()) {
|
|
|
64
121
|
// so this list must persist entries across releases rather than be replaced.
|
|
65
122
|
export function readReleaseTargets(cwd = process.cwd()) {
|
|
66
123
|
const filePath = getBaselineStatePath(cwd);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
return
|
|
124
|
+
const legacyTargets = fs.existsSync(filePath)
|
|
125
|
+
? (parseStateFile(fs.readFileSync(filePath, "utf8"), filePath)[RELEASE_TARGETS_KEY] ?? [])
|
|
126
|
+
: [];
|
|
127
|
+
const packageTargets = readPackageStates(cwd).map((state) => state["release-target"]);
|
|
128
|
+
return [
|
|
129
|
+
...new Map([...legacyTargets, ...packageTargets].map((target) => [
|
|
130
|
+
target.path,
|
|
131
|
+
target,
|
|
132
|
+
])).values(),
|
|
133
|
+
].sort((a, b) => a.path.localeCompare(b.path));
|
|
72
134
|
}
|
|
73
135
|
// The publish set introduced by the current release PR. `release` consumes this
|
|
74
136
|
// so it only publishes/announces what this PR bumped, not every package in the
|
|
75
137
|
// accumulated baseline.
|
|
76
138
|
export function readPendingReleaseTargets(cwd = process.cwd()) {
|
|
139
|
+
return collectPendingReleaseCohorts(cwd, {
|
|
140
|
+
allowPartiallyTagged: true,
|
|
141
|
+
}).flatMap((cohort) => cohort.targets);
|
|
142
|
+
}
|
|
143
|
+
// Every target the release state records, including cohorts whose tags already
|
|
144
|
+
// exist. `release` needs these to tell "this repo never recorded a release"
|
|
145
|
+
// apart from "this release was already published"; only the former may fall
|
|
146
|
+
// back to tagging the root package.
|
|
147
|
+
export function readRecordedPendingReleaseTargets(cwd = process.cwd()) {
|
|
148
|
+
return collectPendingReleaseCohorts(cwd, {
|
|
149
|
+
allowPartiallyTagged: true,
|
|
150
|
+
includeFullyTagged: true,
|
|
151
|
+
}).flatMap((cohort) => cohort.targets);
|
|
152
|
+
}
|
|
153
|
+
export function readPendingReleaseCohorts(cwd = process.cwd()) {
|
|
154
|
+
return collectPendingReleaseCohorts(cwd, {});
|
|
155
|
+
}
|
|
156
|
+
function collectPendingReleaseCohorts(cwd, options) {
|
|
77
157
|
const filePath = getBaselineStatePath(cwd);
|
|
78
|
-
|
|
79
|
-
|
|
158
|
+
const packageStates = readPackageStates(cwd);
|
|
159
|
+
const sidecarPaths = new Set(packageStates.map((state) => state.path));
|
|
160
|
+
const byBranch = new Map();
|
|
161
|
+
for (const state of packageStates) {
|
|
162
|
+
const targets = byBranch.get(state["release-branch"]) ?? [];
|
|
163
|
+
targets.push(state["release-target"]);
|
|
164
|
+
byBranch.set(state["release-branch"], targets);
|
|
165
|
+
}
|
|
166
|
+
if (fs.existsSync(filePath)) {
|
|
167
|
+
const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
168
|
+
const legacyPending = (parsed[PENDING_RELEASE_TARGETS_KEY] ?? []).filter((target) => !sidecarPaths.has(target.path));
|
|
169
|
+
if (legacyPending.length > 0) {
|
|
170
|
+
const branch = loadConfig(cwd).config["release-branch"] ?? "versionary/release";
|
|
171
|
+
byBranch.set(branch, [...(byBranch.get(branch) ?? []), ...legacyPending]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const pending = [];
|
|
175
|
+
for (const [branch, unsortedTargets] of byBranch) {
|
|
176
|
+
const targets = [...unsortedTargets].sort((a, b) => a.path.localeCompare(b.path));
|
|
177
|
+
const tagged = targets.filter((target) => hasLocalTag(cwd, target.tag));
|
|
178
|
+
if (tagged.length === targets.length && !options.includeFullyTagged) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (tagged.length > 0 &&
|
|
182
|
+
tagged.length < targets.length &&
|
|
183
|
+
!options.allowPartiallyTagged) {
|
|
184
|
+
const existingTags = tagged.map((target) => target.tag).join(", ");
|
|
185
|
+
const missingTags = targets
|
|
186
|
+
.filter((target) => !tagged.includes(target))
|
|
187
|
+
.map((target) => target.tag)
|
|
188
|
+
.join(", ");
|
|
189
|
+
throw new Error(`Pending release cohort on ${branch} is partially tagged and cannot move safely. Existing tags: ${existingTags}. Missing tags: ${missingTags}. Rerun the original release workflow to complete it.`);
|
|
190
|
+
}
|
|
191
|
+
pending.push({ branch, targets });
|
|
192
|
+
}
|
|
193
|
+
return pending.sort((a, b) => a.branch.localeCompare(b.branch));
|
|
194
|
+
}
|
|
195
|
+
function hasLocalTag(cwd, tag) {
|
|
196
|
+
try {
|
|
197
|
+
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${tag}`], {
|
|
198
|
+
cwd,
|
|
199
|
+
stdio: "ignore",
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Pending targets remain in the manifest after publishing, so the tag set is
|
|
209
|
+
* the durable evidence that the pending release actually completed.
|
|
210
|
+
*/
|
|
211
|
+
export function hasFullyUntaggedPendingRelease(cwd = process.cwd()) {
|
|
212
|
+
return readPendingReleaseCohorts(cwd).length > 0;
|
|
213
|
+
}
|
|
214
|
+
export function writePackageReleaseState(cwd, baselineSha, releaseTargets, branch) {
|
|
215
|
+
const directory = getPackageStateDirectory(cwd);
|
|
216
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
217
|
+
const written = [];
|
|
218
|
+
for (const target of releaseTargets) {
|
|
219
|
+
const filePath = path.join(directory, packageStateFileName(target.path));
|
|
220
|
+
const state = {
|
|
221
|
+
[MANIFEST_VERSION_KEY]: 1,
|
|
222
|
+
path: target.path,
|
|
223
|
+
[BASELINE_SHA_KEY]: baselineSha,
|
|
224
|
+
"release-target": target,
|
|
225
|
+
"release-branch": branch,
|
|
226
|
+
};
|
|
227
|
+
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
228
|
+
written.push(path.relative(cwd, filePath));
|
|
229
|
+
}
|
|
230
|
+
return written.sort((a, b) => a.localeCompare(b));
|
|
231
|
+
}
|
|
232
|
+
export function hasReleaseStateChangeAtHead(cwd = process.cwd()) {
|
|
233
|
+
const relativeDirectory = path.relative(cwd, getPackageStateDirectory(cwd));
|
|
234
|
+
try {
|
|
235
|
+
const changed = execFileSync("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD^", "HEAD"], {
|
|
236
|
+
cwd,
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
239
|
+
})
|
|
240
|
+
.split("\n")
|
|
241
|
+
.map((entry) => entry.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
return changed.some((entry) => entry === relativeDirectory ||
|
|
244
|
+
entry.startsWith(`${relativeDirectory}${path.sep}`) ||
|
|
245
|
+
entry.startsWith(`${relativeDirectory}/`));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
80
249
|
}
|
|
81
|
-
const parsed = parseStateFile(fs.readFileSync(filePath, "utf8"), filePath);
|
|
82
|
-
return parsed[PENDING_RELEASE_TARGETS_KEY] ?? [];
|
|
83
250
|
}
|
|
84
251
|
export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
|
|
85
252
|
const baselineShaValue = sha ??
|
|
@@ -114,4 +281,10 @@ export function writeBaselineSha(cwd = process.cwd(), sha, releaseTargets) {
|
|
|
114
281
|
[PENDING_RELEASE_TARGETS_KEY]: nextPending,
|
|
115
282
|
};
|
|
116
283
|
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
284
|
+
const written = [path.relative(cwd, filePath)];
|
|
285
|
+
if (releaseTargets !== undefined &&
|
|
286
|
+
fs.existsSync(getPackageStateDirectory(cwd))) {
|
|
287
|
+
written.push(...writePackageReleaseState(cwd, baselineShaValue, releaseTargets, loadConfig(cwd).config["release-branch"] ?? "versionary/release"));
|
|
288
|
+
}
|
|
289
|
+
return written.sort((a, b) => a.localeCompare(b));
|
|
117
290
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReleaseTargetState } from "./state.js";
|
|
2
|
+
export interface ReleaseTargetOrder {
|
|
3
|
+
ordered: ReleaseTargetState[];
|
|
4
|
+
cyclicPaths: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Return a deterministic dependency-first handoff for downstream publishers.
|
|
8
|
+
* Dependencies outside this release set are already published and do not
|
|
9
|
+
* constrain the current targets.
|
|
10
|
+
*
|
|
11
|
+
* A cycle cannot be ordered dependency-first, so the remaining targets are
|
|
12
|
+
* emitted in package-path order instead. The ordering is advisory metadata, so
|
|
13
|
+
* degrading it must not block tagging; callers surface `cyclicPaths` so the
|
|
14
|
+
* cycle still gets reported.
|
|
15
|
+
*/
|
|
16
|
+
export declare function orderReleaseTargets(targets: readonly ReleaseTargetState[]): ReleaseTargetOrder;
|
|
17
|
+
export declare function releaseTargetHandoff(targets: readonly ReleaseTargetState[], logger?: {
|
|
18
|
+
warn: (message: string) => void;
|
|
19
|
+
}): Array<ReleaseTargetState & {
|
|
20
|
+
dependencies: string[];
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
function comparePaths(left, right) {
|
|
2
|
+
return left.localeCompare(right);
|
|
3
|
+
}
|
|
4
|
+
function buildDependencyGraph(targets) {
|
|
5
|
+
const releasingPaths = new Set(targets.map((target) => target.path));
|
|
6
|
+
return new Map(targets.map((target) => [
|
|
7
|
+
target.path,
|
|
8
|
+
new Set((target.dependencies ?? []).filter((dependencyPath) => dependencyPath !== target.path &&
|
|
9
|
+
releasingPaths.has(dependencyPath))),
|
|
10
|
+
]));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Return the paths that actually sit on a dependency cycle, meaning those that
|
|
14
|
+
* are reachable from themselves. Packages that merely depend on a cycle are
|
|
15
|
+
* blocked by it but are not part of it, so naming them would send maintainers
|
|
16
|
+
* to inspect manifests that are fine.
|
|
17
|
+
*/
|
|
18
|
+
function findCyclicPaths(graph) {
|
|
19
|
+
const cyclic = [];
|
|
20
|
+
for (const path of graph.keys()) {
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const stack = [...(graph.get(path) ?? [])];
|
|
23
|
+
let onCycle = false;
|
|
24
|
+
while (stack.length > 0) {
|
|
25
|
+
const current = stack.pop();
|
|
26
|
+
if (current === path) {
|
|
27
|
+
onCycle = true;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (seen.has(current)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
seen.add(current);
|
|
34
|
+
stack.push(...(graph.get(current) ?? []));
|
|
35
|
+
}
|
|
36
|
+
if (onCycle) {
|
|
37
|
+
cyclic.push(path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return cyclic.sort(comparePaths);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Return a deterministic dependency-first handoff for downstream publishers.
|
|
44
|
+
* Dependencies outside this release set are already published and do not
|
|
45
|
+
* constrain the current targets.
|
|
46
|
+
*
|
|
47
|
+
* A cycle cannot be ordered dependency-first, so the remaining targets are
|
|
48
|
+
* emitted in package-path order instead. The ordering is advisory metadata, so
|
|
49
|
+
* degrading it must not block tagging; callers surface `cyclicPaths` so the
|
|
50
|
+
* cycle still gets reported.
|
|
51
|
+
*/
|
|
52
|
+
export function orderReleaseTargets(targets) {
|
|
53
|
+
const graph = buildDependencyGraph(targets);
|
|
54
|
+
const remaining = [...targets].sort((left, right) => comparePaths(left.path, right.path));
|
|
55
|
+
const emitted = new Set();
|
|
56
|
+
const ordered = [];
|
|
57
|
+
while (remaining.length > 0) {
|
|
58
|
+
const readyIndex = remaining.findIndex((target) => [...(graph.get(target.path) ?? [])].every((dependencyPath) => emitted.has(dependencyPath)));
|
|
59
|
+
// Nothing is ready only when every remaining target waits on a cycle;
|
|
60
|
+
// break the deadlock with the first target in package-path order.
|
|
61
|
+
const [target] = remaining.splice(readyIndex === -1 ? 0 : readyIndex, 1);
|
|
62
|
+
if (!target) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
ordered.push(target);
|
|
66
|
+
emitted.add(target.path);
|
|
67
|
+
}
|
|
68
|
+
return { ordered, cyclicPaths: findCyclicPaths(graph) };
|
|
69
|
+
}
|
|
70
|
+
export function releaseTargetHandoff(targets, logger) {
|
|
71
|
+
const { ordered, cyclicPaths } = orderReleaseTargets(targets);
|
|
72
|
+
if (cyclicPaths.length > 0) {
|
|
73
|
+
const quoted = cyclicPaths.map((path) => `"${path}"`).join(", ");
|
|
74
|
+
logger?.warn(`Release dependencies contain a cycle: ${quoted}. Publishing order for those packages falls back to package-path order.`);
|
|
75
|
+
}
|
|
76
|
+
const releasingPaths = new Set(targets.map((target) => target.path));
|
|
77
|
+
return ordered.map((target) => ({
|
|
78
|
+
...target,
|
|
79
|
+
// Dependencies outside the release set are already published, and the
|
|
80
|
+
// documented contract is that every entry names a target in this handoff.
|
|
81
|
+
dependencies: [...new Set(target.dependencies ?? [])]
|
|
82
|
+
.filter((dependencyPath) => dependencyPath !== target.path && releasingPaths.has(dependencyPath))
|
|
83
|
+
.sort(comparePaths),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
@@ -223,6 +223,50 @@ export function createGitHubPlugin() {
|
|
|
223
223
|
state: toReviewRequestState(created, `pull request #${created.number} in ${repoRef(repo)}`),
|
|
224
224
|
};
|
|
225
225
|
},
|
|
226
|
+
async listOpenReviewRequests(input, _context) {
|
|
227
|
+
const repo = getRepoFromEnv();
|
|
228
|
+
const octokit = new Octokit({ auth: getGitHubToken() });
|
|
229
|
+
const pulls = [];
|
|
230
|
+
try {
|
|
231
|
+
for (let page = 1;; page += 1) {
|
|
232
|
+
const response = await octokit.pulls.list({
|
|
233
|
+
owner: repo.owner,
|
|
234
|
+
repo: repo.repo,
|
|
235
|
+
state: "open",
|
|
236
|
+
base: input.baseBranch,
|
|
237
|
+
per_page: 100,
|
|
238
|
+
page,
|
|
239
|
+
});
|
|
240
|
+
pulls.push(...response.data);
|
|
241
|
+
if (response.data.length < 100) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
const { message } = parseGitHubError(error);
|
|
248
|
+
throw new Error(`Failed listing open pull requests into "${input.baseBranch}": [${repoRef(repo)}] ${message}`);
|
|
249
|
+
}
|
|
250
|
+
const requiredLabels = new Set(input.labels ?? []);
|
|
251
|
+
return pulls
|
|
252
|
+
.filter((pull) => pull.head.ref.startsWith(input.headBranchPrefix))
|
|
253
|
+
.filter((pull) => {
|
|
254
|
+
const labels = new Set(pull.labels
|
|
255
|
+
.map((label) => typeof label === "string" ? label : (label.name ?? ""))
|
|
256
|
+
.filter(Boolean));
|
|
257
|
+
return [...requiredLabels].every((label) => labels.has(label));
|
|
258
|
+
})
|
|
259
|
+
.map((pull) => ({
|
|
260
|
+
id: String(pull.id),
|
|
261
|
+
number: pull.number,
|
|
262
|
+
url: pull.html_url,
|
|
263
|
+
state: toReviewRequestState(pull, `pull request #${pull.number} in ${repoRef(repo)}`),
|
|
264
|
+
baseBranch: pull.base.ref,
|
|
265
|
+
headBranch: pull.head.ref,
|
|
266
|
+
title: pull.title,
|
|
267
|
+
}))
|
|
268
|
+
.sort((a, b) => a.headBranch.localeCompare(b.headBranch));
|
|
269
|
+
},
|
|
226
270
|
async closeReviewRequestIfExists(input, _context) {
|
|
227
271
|
const repo = getRepoFromEnv();
|
|
228
272
|
const octokit = new Octokit({ auth: getGitHubToken() });
|
package/dist/scm/types.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface ScmReviewRequestResult {
|
|
|
20
20
|
url: string;
|
|
21
21
|
state: "open" | "closed" | "merged";
|
|
22
22
|
}
|
|
23
|
+
export interface ScmListReviewRequestsInput {
|
|
24
|
+
baseBranch: string;
|
|
25
|
+
headBranchPrefix: string;
|
|
26
|
+
labels?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface ScmReviewRequestSummary extends ScmReviewRequestResult {
|
|
29
|
+
baseBranch: string;
|
|
30
|
+
headBranch: string;
|
|
31
|
+
title: string;
|
|
32
|
+
}
|
|
23
33
|
export interface ScmCloseReviewRequestInput {
|
|
24
34
|
baseBranch: string;
|
|
25
35
|
headBranch: string;
|
|
@@ -58,6 +68,7 @@ export interface ScmReleaseReferenceCommentsResult {
|
|
|
58
68
|
export interface ScmClient {
|
|
59
69
|
provider: ScmProvider;
|
|
60
70
|
createOrUpdateReviewRequest: (input: ScmReviewRequestInput, context: ScmClientContext) => Promise<ScmReviewRequestResult>;
|
|
71
|
+
listOpenReviewRequests: (input: ScmListReviewRequestsInput, context: ScmClientContext) => Promise<ScmReviewRequestSummary[]>;
|
|
61
72
|
closeReviewRequestIfExists: (input: ScmCloseReviewRequestInput, context: ScmClientContext) => Promise<ScmCloseReviewRequestResult>;
|
|
62
73
|
createReleaseMetadata: (input: ScmReleaseMetadataInput, context: ScmClientContext) => Promise<ScmReleaseMetadataResult>;
|
|
63
74
|
createReleaseReferenceComments?: (input: ScmReleaseReferenceCommentsInput, context: ScmClientContext) => Promise<ScmReleaseReferenceCommentsResult>;
|