@git.zone/cli 5.0.0 → 6.0.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.
Files changed (29) hide show
  1. package/assets/templates/ci_default/.gitea/workflows/default_tags.yaml +0 -21
  2. package/assets/templates/ci_default_gitlab/.gitlab-ci.yml +0 -13
  3. package/assets/templates/ci_default_private/.gitea/workflows/default_tags.yaml +0 -21
  4. package/assets/templates/ci_default_private_gitlab/.gitlab-ci.yml +0 -13
  5. package/dist_ts/00_commitinfo_data.js +1 -1
  6. package/dist_ts/helpers.climode.js +31 -2
  7. package/dist_ts/helpers.workflow.d.ts +10 -0
  8. package/dist_ts/helpers.workflow.js +80 -8
  9. package/dist_ts/mod_release/classes.releasejournal.d.ts +78 -0
  10. package/dist_ts/mod_release/classes.releasejournal.js +511 -0
  11. package/dist_ts/mod_release/helpers.npmartifact.d.ts +32 -0
  12. package/dist_ts/mod_release/helpers.npmartifact.js +358 -0
  13. package/dist_ts/mod_release/helpers.releasebranch.d.ts +1 -0
  14. package/dist_ts/mod_release/helpers.releasebranch.js +54 -6
  15. package/dist_ts/mod_release/helpers.releasepublication.d.ts +24 -0
  16. package/dist_ts/mod_release/helpers.releasepublication.js +293 -0
  17. package/dist_ts/mod_release/index.d.ts +1 -11
  18. package/dist_ts/mod_release/index.js +410 -288
  19. package/package.json +1 -1
  20. package/readme.hints.md +47 -1
  21. package/readme.md +61 -26
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/helpers.climode.ts +34 -1
  24. package/ts/helpers.workflow.ts +114 -7
  25. package/ts/mod_release/classes.releasejournal.ts +740 -0
  26. package/ts/mod_release/helpers.npmartifact.ts +553 -0
  27. package/ts/mod_release/helpers.releasebranch.ts +67 -5
  28. package/ts/mod_release/helpers.releasepublication.ts +641 -0
  29. package/ts/mod_release/index.ts +576 -411
@@ -0,0 +1,553 @@
1
+ import * as plugins from "./mod.plugins.js";
2
+ import {
3
+ releaseArtifactFileName,
4
+ type IReleaseArtifact,
5
+ } from "./classes.releasejournal.js";
6
+ import { assertCredentialFreeGitPushDestination } from "./helpers.releasebranch.js";
7
+
8
+ const supportedPnpmVersion = "11.21.0";
9
+ const maximumMetadataResponseBytes = 8 * 1024 * 1024;
10
+ const capabilityCommandTimeoutMs = 30_000;
11
+ const packCommandTimeoutMs = 10 * 60_000;
12
+ const publishCommandTimeoutMs = 5 * 60_000;
13
+ const packageNameRegex = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
14
+
15
+ export interface IPnpmReleaseCapability {
16
+ version: typeof supportedPnpmVersion;
17
+ }
18
+
19
+ export type TNpmArtifactProbeStatus =
20
+ | "absent"
21
+ | "exact"
22
+ | "conflict"
23
+ | "inconclusive";
24
+
25
+ export interface INpmArtifactProbeResult {
26
+ status: TNpmArtifactProbeStatus;
27
+ code:
28
+ | "absent"
29
+ | "exact"
30
+ | "metadata-conflict"
31
+ | "tarball-conflict"
32
+ | "tag-conflict"
33
+ | "registry-response"
34
+ | "network-error";
35
+ }
36
+
37
+ export interface INpmArtifactProbeOptions {
38
+ fetchImplementation?: typeof fetch;
39
+ timeoutMs?: number;
40
+ attempts?: number;
41
+ delayMs?: number;
42
+ }
43
+
44
+ const readPackageIdentity = async (
45
+ cwdArg: string,
46
+ ): Promise<{ packageName: string; version: string }> => {
47
+ let packageJson: unknown;
48
+ try {
49
+ packageJson = JSON.parse(
50
+ await plugins.fs.readFile(plugins.path.join(cwdArg, "package.json"), "utf8"),
51
+ );
52
+ } catch (error) {
53
+ throw new Error("Unable to read package.json for npm release packaging.", {
54
+ cause: error,
55
+ });
56
+ }
57
+ if (
58
+ typeof packageJson !== "object" ||
59
+ packageJson === null ||
60
+ Array.isArray(packageJson) ||
61
+ typeof (packageJson as { name?: unknown }).name !== "string" ||
62
+ !packageNameRegex.test((packageJson as { name: string }).name) ||
63
+ typeof (packageJson as { version?: unknown }).version !== "string"
64
+ ) {
65
+ throw new Error("package.json requires a canonical npm name and version.");
66
+ }
67
+ return {
68
+ packageName: (packageJson as { name: string }).name,
69
+ version: (packageJson as { version: string }).version,
70
+ };
71
+ };
72
+
73
+ const requireHelpFragments = (
74
+ outputArg: string,
75
+ fragmentsArg: string[],
76
+ commandArg: string,
77
+ ): void => {
78
+ const missing = fragmentsArg.filter((fragmentArg) => !outputArg.includes(fragmentArg));
79
+ if (missing.length > 0) {
80
+ throw new Error(
81
+ `pnpm ${commandArg} is missing required exact-release capabilities: ${missing.join(", ")}.`,
82
+ );
83
+ }
84
+ };
85
+
86
+ export const assertPnpmReleaseCapability = async (
87
+ smartshellArg: plugins.smartshell.Smartshell,
88
+ cwdArg: string,
89
+ ): Promise<IPnpmReleaseCapability> => {
90
+ const versionResult = await smartshellArg.execSpawn("pnpm", ["--version"], {
91
+ cwd: cwdArg,
92
+ silent: true,
93
+ timeout: capabilityCommandTimeoutMs,
94
+ timeoutKillGraceMs: 5_000,
95
+ });
96
+ const version = versionResult.stdout.trim();
97
+ if (versionResult.exitCode !== 0 || version !== supportedPnpmVersion) {
98
+ throw new Error(
99
+ `Exact release packaging requires the verified pnpm ${supportedPnpmVersion}; resolved ${version || "unknown"}.`,
100
+ );
101
+ }
102
+
103
+ const packHelp = await smartshellArg.execSpawn("pnpm", ["pack", "--help"], {
104
+ cwd: cwdArg,
105
+ silent: true,
106
+ timeout: capabilityCommandTimeoutMs,
107
+ timeoutKillGraceMs: 5_000,
108
+ });
109
+ if (packHelp.exitCode !== 0) {
110
+ throw new Error("Unable to inspect pnpm pack capabilities.");
111
+ }
112
+ requireHelpFragments(packHelp.combinedOutput, ["--out <path>", "--json"], "pack");
113
+
114
+ const publishHelp = await smartshellArg.execSpawn(
115
+ "pnpm",
116
+ ["publish", "--help"],
117
+ {
118
+ cwd: cwdArg,
119
+ silent: true,
120
+ timeout: capabilityCommandTimeoutMs,
121
+ timeoutKillGraceMs: 5_000,
122
+ },
123
+ );
124
+ if (publishHelp.exitCode !== 0) {
125
+ throw new Error("Unable to inspect pnpm publish capabilities.");
126
+ }
127
+ requireHelpFragments(
128
+ publishHelp.combinedOutput,
129
+ [
130
+ "publish [<tarball>|<dir>]",
131
+ "--no-git-checks",
132
+ "--ignore-scripts",
133
+ "--json",
134
+ "--tag <tag>",
135
+ "--access <public|restricted>",
136
+ ],
137
+ "publish",
138
+ );
139
+ return { version: supportedPnpmVersion };
140
+ };
141
+
142
+ const legacyPublishWorkflowPaths = [
143
+ ".gitea/workflows/default_tags.yaml",
144
+ ".gitlab-ci.yml",
145
+ ] as const;
146
+
147
+ export const assertNoLegacyNpmPublisher = async (cwdArg: string): Promise<void> => {
148
+ for (const relativePath of legacyPublishWorkflowPaths) {
149
+ const filePath = plugins.path.join(cwdArg, relativePath);
150
+ let content: string;
151
+ try {
152
+ content = await plugins.fs.readFile(filePath, "utf8");
153
+ } catch (error) {
154
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
155
+ continue;
156
+ }
157
+ throw new Error(`Unable to inspect ${relativePath} for legacy npm publication.`, {
158
+ cause: error,
159
+ });
160
+ }
161
+ const activeContent = content
162
+ .split("\n")
163
+ .filter((lineArg) => !/^\s*#/.test(lineArg))
164
+ .join("\n");
165
+ if (/\bnpmci\s+npm\s+publish\b/.test(activeContent)) {
166
+ throw new Error(
167
+ `${relativePath} still contains the legacy independent npm publisher. ` +
168
+ "Remove it or apply the Gitzone v6 Gitea workflow template, then commit the workflow change before releasing.",
169
+ );
170
+ }
171
+ }
172
+ };
173
+
174
+ export const normalizeNpmRegistryUrl = (valueArg: string): string => {
175
+ if (typeof valueArg !== "string" || !valueArg.trim()) {
176
+ throw new Error("npm registry URLs must be non-empty strings.");
177
+ }
178
+ let url: URL;
179
+ try {
180
+ url = new URL(valueArg.trim());
181
+ } catch (error) {
182
+ throw new Error("Invalid npm registry URL.", { cause: error });
183
+ }
184
+ if (
185
+ (url.protocol !== "https:" && url.protocol !== "http:") ||
186
+ url.username ||
187
+ url.password ||
188
+ url.search ||
189
+ url.hash
190
+ ) {
191
+ throw new Error(
192
+ "npm registry URLs must use credential-free http(s) without query strings or fragments.",
193
+ );
194
+ }
195
+ const pathname = url.pathname.replace(/\/+$/, "");
196
+ return `${url.origin}${pathname}`;
197
+ };
198
+
199
+ export const hashReleaseDestination = (valueArg: string): string => {
200
+ assertCredentialFreeGitPushDestination(valueArg);
201
+ return plugins.crypto.createHash("sha256").update(valueArg).digest("hex");
202
+ };
203
+
204
+ export const calculateNpmArtifact = async (
205
+ artifactPathArg: string,
206
+ packageNameArg: string,
207
+ versionArg: string,
208
+ ): Promise<IReleaseArtifact> => {
209
+ const stat = await plugins.fs.lstat(artifactPathArg);
210
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0) {
211
+ throw new Error("Release npm artifact must be a non-empty regular file.");
212
+ }
213
+ const bytes = await plugins.fs.readFile(artifactPathArg);
214
+ return {
215
+ kind: "npm-tarball",
216
+ file: releaseArtifactFileName,
217
+ packageName: packageNameArg,
218
+ version: versionArg,
219
+ size: bytes.byteLength,
220
+ sha1: plugins.crypto.createHash("sha1").update(bytes).digest("hex"),
221
+ sha256: plugins.crypto.createHash("sha256").update(bytes).digest("hex"),
222
+ integrity: `sha512-${plugins.crypto.createHash("sha512").update(bytes).digest("base64")}`,
223
+ };
224
+ };
225
+
226
+ const syncFile = async (filePathArg: string): Promise<void> => {
227
+ const handle = await plugins.fs.open(filePathArg, "r");
228
+ try {
229
+ await handle.sync();
230
+ } finally {
231
+ await handle.close();
232
+ }
233
+ };
234
+
235
+ export const packNpmArtifact = async (
236
+ smartshellArg: plugins.smartshell.Smartshell,
237
+ cwdArg: string,
238
+ destinationDirectoryArg: string,
239
+ expectedVersionArg: string,
240
+ ): Promise<IReleaseArtifact> => {
241
+ const identity = await readPackageIdentity(cwdArg);
242
+ if (identity.version !== expectedVersionArg) {
243
+ throw new Error(
244
+ `package.json version ${identity.version} does not match release ${expectedVersionArg}.`,
245
+ );
246
+ }
247
+ const artifactPath = plugins.path.join(
248
+ destinationDirectoryArg,
249
+ releaseArtifactFileName,
250
+ );
251
+ const result = await smartshellArg.execSpawn(
252
+ "pnpm",
253
+ ["pack", "--out", artifactPath, "--json"],
254
+ {
255
+ cwd: cwdArg,
256
+ timeout: packCommandTimeoutMs,
257
+ timeoutKillGraceMs: 5_000,
258
+ },
259
+ );
260
+ if (result.exitCode !== 0) {
261
+ throw new Error("pnpm pack failed while creating the exact release artifact.");
262
+ }
263
+ let report: unknown;
264
+ try {
265
+ report = JSON.parse(result.stdout.trim());
266
+ } catch (error) {
267
+ throw new Error("pnpm pack did not return its required JSON report.", {
268
+ cause: error,
269
+ });
270
+ }
271
+ if (
272
+ typeof report !== "object" ||
273
+ report === null ||
274
+ Array.isArray(report) ||
275
+ (report as { name?: unknown }).name !== identity.packageName ||
276
+ (report as { version?: unknown }).version !== identity.version
277
+ ) {
278
+ throw new Error("pnpm pack reported a different package identity.");
279
+ }
280
+ await syncFile(artifactPath);
281
+ return calculateNpmArtifact(
282
+ artifactPath,
283
+ identity.packageName,
284
+ identity.version,
285
+ );
286
+ };
287
+
288
+ export const verifyStoredNpmArtifact = async (
289
+ artifactPathArg: string,
290
+ expectedArtifactArg: IReleaseArtifact,
291
+ ): Promise<void> => {
292
+ const actual = await calculateNpmArtifact(
293
+ artifactPathArg,
294
+ expectedArtifactArg.packageName,
295
+ expectedArtifactArg.version,
296
+ );
297
+ if (
298
+ actual.size !== expectedArtifactArg.size ||
299
+ actual.sha1 !== expectedArtifactArg.sha1 ||
300
+ actual.sha256 !== expectedArtifactArg.sha256 ||
301
+ actual.integrity !== expectedArtifactArg.integrity
302
+ ) {
303
+ throw new Error("Stored npm artifact no longer matches its release journal identity.");
304
+ }
305
+ };
306
+
307
+ export const buildPnpmPublishArgs = (
308
+ artifactPathArg: string,
309
+ registryArg: string,
310
+ ): string[] => [
311
+ "publish",
312
+ artifactPathArg,
313
+ `--registry=${normalizeNpmRegistryUrl(registryArg)}`,
314
+ "--access=public",
315
+ "--tag=latest",
316
+ "--no-git-checks",
317
+ "--ignore-scripts",
318
+ "--json",
319
+ ];
320
+
321
+ export const publishNpmArtifact = async (
322
+ smartshellArg: plugins.smartshell.Smartshell,
323
+ cwdArg: string,
324
+ artifactPathArg: string,
325
+ registryArg: string,
326
+ ): Promise<{ exitCode: number; output: string }> => {
327
+ const result = await smartshellArg.execSpawn(
328
+ "pnpm",
329
+ buildPnpmPublishArgs(artifactPathArg, registryArg),
330
+ {
331
+ cwd: cwdArg,
332
+ timeout: publishCommandTimeoutMs,
333
+ timeoutKillGraceMs: 5_000,
334
+ },
335
+ );
336
+ return { exitCode: result.exitCode, output: result.combinedOutput };
337
+ };
338
+
339
+ const getFetch = (optionsArg: INpmArtifactProbeOptions): typeof fetch =>
340
+ optionsArg.fetchImplementation || globalThis.fetch;
341
+
342
+ const fetchWithoutRedirects = async (
343
+ urlArg: URL,
344
+ acceptArg: string,
345
+ optionsArg: INpmArtifactProbeOptions,
346
+ ): Promise<Response> => {
347
+ const timeoutMs = optionsArg.timeoutMs ?? 10_000;
348
+ return getFetch(optionsArg)(urlArg, {
349
+ method: "GET",
350
+ headers: { accept: acceptArg },
351
+ redirect: "manual",
352
+ signal: AbortSignal.timeout(timeoutMs),
353
+ });
354
+ };
355
+
356
+ const cancelResponseBody = (responseArg: Response): void => {
357
+ if (responseArg.body && !responseArg.body.locked) {
358
+ void responseArg.body.cancel().catch(() => {});
359
+ }
360
+ };
361
+
362
+ const readBoundedResponse = async (
363
+ responseArg: Response,
364
+ maximumSizeArg: number,
365
+ contextArg: string,
366
+ ): Promise<Buffer> => {
367
+ const contentLength = responseArg.headers.get("content-length");
368
+ if (
369
+ contentLength &&
370
+ (!/^\d+$/.test(contentLength) || Number(contentLength) > maximumSizeArg)
371
+ ) {
372
+ cancelResponseBody(responseArg);
373
+ throw new Error(`${contextArg} exceeds its maximum response size.`);
374
+ }
375
+ if (!responseArg.body) {
376
+ throw new Error(`${contextArg} response has no body.`);
377
+ }
378
+ const chunks: Uint8Array[] = [];
379
+ let size = 0;
380
+ const reader = responseArg.body.getReader();
381
+ let complete = false;
382
+ try {
383
+ while (true) {
384
+ const item = await reader.read();
385
+ if (item.done) {
386
+ complete = true;
387
+ break;
388
+ }
389
+ size += item.value.byteLength;
390
+ if (size > maximumSizeArg) {
391
+ throw new Error(`${contextArg} exceeds its maximum response size.`);
392
+ }
393
+ chunks.push(item.value);
394
+ }
395
+ } finally {
396
+ if (!complete) {
397
+ void reader.cancel().catch(() => {});
398
+ }
399
+ reader.releaseLock();
400
+ }
401
+ return Buffer.concat(chunks.map((chunkArg) => Buffer.from(chunkArg)), size);
402
+ };
403
+
404
+ const readBoundedJsonResponse = async (responseArg: Response): Promise<unknown> =>
405
+ JSON.parse(
406
+ (
407
+ await readBoundedResponse(
408
+ responseArg,
409
+ maximumMetadataResponseBytes,
410
+ "Registry metadata",
411
+ )
412
+ ).toString("utf8"),
413
+ );
414
+
415
+ const getMetadataUrl = (
416
+ registryArg: string,
417
+ packageNameArg: string,
418
+ versionArg?: string,
419
+ ): URL => {
420
+ const encodedPackage = encodeURIComponent(packageNameArg);
421
+ return new URL(
422
+ versionArg
423
+ ? `${normalizeNpmRegistryUrl(registryArg)}/${encodedPackage}/${encodeURIComponent(versionArg)}`
424
+ : `${normalizeNpmRegistryUrl(registryArg)}/${encodedPackage}`,
425
+ );
426
+ };
427
+
428
+ export const probeAnonymousNpmArtifact = async (
429
+ registryArg: string,
430
+ artifactArg: IReleaseArtifact,
431
+ tagArg = "latest",
432
+ optionsArg: INpmArtifactProbeOptions = {},
433
+ ): Promise<INpmArtifactProbeResult> => {
434
+ const registry = normalizeNpmRegistryUrl(registryArg);
435
+ try {
436
+ const versionResponse = await fetchWithoutRedirects(
437
+ getMetadataUrl(registry, artifactArg.packageName, artifactArg.version),
438
+ "application/json",
439
+ optionsArg,
440
+ );
441
+ if (versionResponse.status === 404) {
442
+ cancelResponseBody(versionResponse);
443
+ return { status: "absent", code: "absent" };
444
+ }
445
+ if (versionResponse.status !== 200) {
446
+ cancelResponseBody(versionResponse);
447
+ return { status: "inconclusive", code: "registry-response" };
448
+ }
449
+ const metadata = (await readBoundedJsonResponse(versionResponse)) as {
450
+ name?: unknown;
451
+ version?: unknown;
452
+ dist?: { integrity?: unknown; shasum?: unknown; tarball?: unknown };
453
+ };
454
+ if (
455
+ metadata.name !== artifactArg.packageName ||
456
+ metadata.version !== artifactArg.version ||
457
+ metadata.dist?.integrity !== artifactArg.integrity ||
458
+ metadata.dist?.shasum !== artifactArg.sha1 ||
459
+ typeof metadata.dist?.tarball !== "string"
460
+ ) {
461
+ return { status: "conflict", code: "metadata-conflict" };
462
+ }
463
+
464
+ const tarballUrl = new URL(metadata.dist.tarball);
465
+ const registryUrl = new URL(registry);
466
+ if (
467
+ tarballUrl.origin !== registryUrl.origin ||
468
+ (tarballUrl.protocol !== "https:" && tarballUrl.protocol !== "http:") ||
469
+ tarballUrl.username ||
470
+ tarballUrl.password ||
471
+ tarballUrl.search ||
472
+ tarballUrl.hash
473
+ ) {
474
+ return { status: "conflict", code: "metadata-conflict" };
475
+ }
476
+ const tarballResponse = await fetchWithoutRedirects(
477
+ tarballUrl,
478
+ "application/octet-stream",
479
+ optionsArg,
480
+ );
481
+ if (tarballResponse.status !== 200) {
482
+ cancelResponseBody(tarballResponse);
483
+ return { status: "inconclusive", code: "registry-response" };
484
+ }
485
+ const bytes = await readBoundedResponse(
486
+ tarballResponse,
487
+ artifactArg.size,
488
+ "Registry tarball",
489
+ );
490
+ if (
491
+ bytes.byteLength !== artifactArg.size ||
492
+ plugins.crypto.createHash("sha1").update(bytes).digest("hex") !==
493
+ artifactArg.sha1 ||
494
+ plugins.crypto.createHash("sha256").update(bytes).digest("hex") !==
495
+ artifactArg.sha256 ||
496
+ `sha512-${plugins.crypto.createHash("sha512").update(bytes).digest("base64")}` !==
497
+ artifactArg.integrity
498
+ ) {
499
+ return { status: "conflict", code: "tarball-conflict" };
500
+ }
501
+
502
+ const packageResponse = await fetchWithoutRedirects(
503
+ getMetadataUrl(registry, artifactArg.packageName),
504
+ "application/json",
505
+ optionsArg,
506
+ );
507
+ if (packageResponse.status !== 200) {
508
+ cancelResponseBody(packageResponse);
509
+ return { status: "inconclusive", code: "registry-response" };
510
+ }
511
+ const packageMetadata = (await readBoundedJsonResponse(packageResponse)) as {
512
+ ["dist-tags"]?: Record<string, unknown>;
513
+ };
514
+ if (packageMetadata["dist-tags"]?.[tagArg] !== artifactArg.version) {
515
+ return { status: "inconclusive", code: "tag-conflict" };
516
+ }
517
+ return { status: "exact", code: "exact" };
518
+ } catch {
519
+ return { status: "inconclusive", code: "network-error" };
520
+ }
521
+ };
522
+
523
+ export const waitForAnonymousNpmArtifact = async (
524
+ registryArg: string,
525
+ artifactArg: IReleaseArtifact,
526
+ tagArg = "latest",
527
+ optionsArg: INpmArtifactProbeOptions = {},
528
+ ): Promise<INpmArtifactProbeResult> => {
529
+ const attempts = optionsArg.attempts ?? 10;
530
+ const delayMs = optionsArg.delayMs ?? 1_000;
531
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
532
+ throw new Error("npm verification attempts must be a positive safe integer.");
533
+ }
534
+ let lastResult: INpmArtifactProbeResult = {
535
+ status: "inconclusive",
536
+ code: "network-error",
537
+ };
538
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
539
+ lastResult = await probeAnonymousNpmArtifact(
540
+ registryArg,
541
+ artifactArg,
542
+ tagArg,
543
+ optionsArg,
544
+ );
545
+ if (lastResult.status === "exact" || lastResult.status === "conflict") {
546
+ return lastResult;
547
+ }
548
+ if (attempt + 1 < attempts) {
549
+ await plugins.smartdelay.delayFor(delayMs);
550
+ }
551
+ }
552
+ return lastResult;
553
+ };
@@ -49,6 +49,65 @@ export const releaseGitEnv: NodeJS.ProcessEnv = {
49
49
  GIT_NO_REPLACE_OBJECTS: "1",
50
50
  };
51
51
 
52
+ export const assertCredentialFreeGitPushDestination = (
53
+ valueArg: string,
54
+ ): void => {
55
+ if (
56
+ !valueArg ||
57
+ valueArg.trim() !== valueArg ||
58
+ /[\u0000-\u001f\u007f]/.test(valueArg)
59
+ ) {
60
+ throw new Error("Git push destinations must be non-empty canonical strings.");
61
+ }
62
+ if (
63
+ plugins.path.isAbsolute(valueArg) ||
64
+ /^[A-Za-z]:[\\/]/.test(valueArg) ||
65
+ valueArg.startsWith("./") ||
66
+ valueArg.startsWith("../") ||
67
+ valueArg.startsWith("~/")
68
+ ) {
69
+ return;
70
+ }
71
+ if (/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:.+$/.test(valueArg)) {
72
+ return;
73
+ }
74
+
75
+ let url: URL;
76
+ try {
77
+ url = new URL(valueArg);
78
+ } catch (error) {
79
+ if (/^[A-Za-z0-9._/-]+$/.test(valueArg)) {
80
+ return;
81
+ }
82
+ throw new Error("Git push destination syntax is unsupported.", {
83
+ cause: error,
84
+ });
85
+ }
86
+ const sshProtocol =
87
+ url.protocol === "ssh:" ||
88
+ url.protocol === "git+ssh:" ||
89
+ url.protocol === "ssh+git:";
90
+ if (
91
+ !sshProtocol &&
92
+ url.protocol !== "https:" &&
93
+ url.protocol !== "http:" &&
94
+ url.protocol !== "git:" &&
95
+ url.protocol !== "file:"
96
+ ) {
97
+ throw new Error("Git push destination protocol is unsupported.");
98
+ }
99
+ if (
100
+ url.password ||
101
+ url.search ||
102
+ url.hash ||
103
+ (!sshProtocol && url.username)
104
+ ) {
105
+ throw new Error(
106
+ "Git push destinations must not contain embedded credentials or parameters.",
107
+ );
108
+ }
109
+ };
110
+
52
111
  const readOnlyGitEnv: NodeJS.ProcessEnv = {
53
112
  ...releaseGitEnv,
54
113
  GIT_NO_LAZY_FETCH: "1",
@@ -116,6 +175,9 @@ const runGit = async (
116
175
  cwd: cwdArg,
117
176
  env: readOnlyArg ? readOnlyGitEnv : releaseGitEnv,
118
177
  silent: true,
178
+ timeout:
179
+ argsArg[0] === "fetch" || argsArg[0] === "push" ? 5 * 60_000 : 60_000,
180
+ timeoutKillGraceMs: 5_000,
119
181
  });
120
182
 
121
183
  const requireGitSuccess = (
@@ -365,15 +427,15 @@ const resolvePushUrl = async (
365
427
  const hasUrlScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(pushUrl);
366
428
  const isScpStyle = /^(?:[^@/\\:]+@)?[^/\\:]+:.+/.test(pushUrl);
367
429
  const isWindowsAbsolutePath = /^[a-z]:[\\/]/i.test(pushUrl);
368
- if (
430
+ const resolvedPushUrl =
369
431
  !hasUrlScheme &&
370
432
  !isScpStyle &&
371
433
  !plugins.path.isAbsolute(pushUrl) &&
372
434
  !isWindowsAbsolutePath
373
- ) {
374
- return plugins.path.resolve(cwdArg, pushUrl);
375
- }
376
- return pushUrl;
435
+ ? plugins.path.resolve(cwdArg, pushUrl)
436
+ : pushUrl;
437
+ assertCredentialFreeGitPushDestination(resolvedPushUrl);
438
+ return resolvedPushUrl;
377
439
  };
378
440
 
379
441
  const resolveRemoteMainOid = async (