@teambit/ci 1.0.636 → 1.0.638
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/ci.docs.mdx +13 -7
- package/commands/sync.cmd.ts +5 -2
- package/dist/ci.docs.mdx +13 -7
- package/dist/ci.main.runtime.d.ts +6 -4
- package/dist/ci.main.runtime.js +19 -13
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/commands/sync.cmd.js +5 -2
- package/dist/commands/sync.cmd.js.map +1 -1
- package/dist/{preview-1792527927744.js → preview-1792591009402.js} +1 -1
- package/dist/sync/init-scaffold.d.ts +89 -12
- package/dist/sync/init-scaffold.js +208 -21
- package/dist/sync/init-scaffold.js.map +1 -1
- package/dist/sync/init-scaffold.spec.js +295 -45
- package/dist/sync/init-scaffold.spec.js.map +1 -1
- package/dist/sync/sync-orchestrator.d.ts +3 -1
- package/dist/sync/sync-orchestrator.js +84 -14
- package/dist/sync/sync-orchestrator.js.map +1 -1
- package/package.json +23 -23
- package/sync/init-scaffold.spec.ts +314 -52
- package/sync/init-scaffold.ts +341 -55
- package/sync/sync-orchestrator.ts +89 -22
package/sync/init-scaffold.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { formatHint, formatItem, formatTitle, formatWarningSummary, joinSections, successSymbol } from '@teambit/cli';
|
|
2
4
|
import path from 'path';
|
|
3
5
|
import { parseGitHubRepo, isGitHubRemote, gitHubApiUrl, remoteHost } from './github-client';
|
|
4
6
|
|
|
@@ -7,9 +9,10 @@ import { parseGitHubRepo, isGitHubRemote, gitHubApiUrl, remoteHost } from './git
|
|
|
7
9
|
*
|
|
8
10
|
* TEMPLATE SOURCE OF TRUTH: `BIT_SYNC_WORKFLOW_RAW` / `BIT_RELEASE_WORKFLOW_RAW` are byte-for-byte
|
|
9
11
|
* copies of the canonical templates in the `bit-git-sync` action workspace
|
|
10
|
-
* (git-sync/git-sync/workflows/templates/) and MUST be kept in sync with them. The ONLY
|
|
11
|
-
* the two `DEFAULT_BRANCH_MARKER` substitution points
|
|
12
|
-
*
|
|
12
|
+
* (git-sync/git-sync/workflows/templates/) and MUST be kept in sync with them. The ONLY departures
|
|
13
|
+
* are the two `DEFAULT_BRANCH_MARKER` substitution points and, for a workspace below the repository
|
|
14
|
+
* root, the `WS_DIR_MARKER` line; everything else — CHANGE-ME comments, the placeholder action ref,
|
|
15
|
+
* `bit-sync/main` config defaults — stays untouched.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
/** Where `scaffoldWorkflowFiles` writes each file, relative to the workspace root. */
|
|
@@ -235,14 +238,35 @@ function substituteDefaultBranch(raw: string, marker: string, defaultBranch: str
|
|
|
235
238
|
return raw.replace(marker, () => substitutedMarker);
|
|
236
239
|
}
|
|
237
240
|
|
|
238
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
/** The commented-out `ws-dir` input, once in each template. */
|
|
242
|
+
const WS_DIR_MARKER = '# ws-dir: packages/my-workspace';
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Set the action's `ws-dir` input when the workspace is below the repository root (`wsDir` is the
|
|
246
|
+
* workspace directory relative to the root, `/`-separated). At the root, the line stays commented out.
|
|
247
|
+
*/
|
|
248
|
+
function substituteWsDir(raw: string, wsDir: string | undefined): string {
|
|
249
|
+
if (!wsDir || wsDir === '.') return raw;
|
|
250
|
+
if (!raw.includes(WS_DIR_MARKER)) {
|
|
251
|
+
throw new Error(`init-scaffold: expected marker "${WS_DIR_MARKER}" not found in its workflow template`);
|
|
252
|
+
}
|
|
253
|
+
return raw.replace(WS_DIR_MARKER, () => `ws-dir: ${yamlSingleQuoted(wsDir)}`);
|
|
241
254
|
}
|
|
242
255
|
|
|
243
|
-
/** Render `bit-
|
|
244
|
-
export function
|
|
245
|
-
return
|
|
256
|
+
/** Render `bit-sync.yml` with the repository's actual default branch, and `ws-dir` for a nested workspace. */
|
|
257
|
+
export function renderBitSyncWorkflow(defaultBranch: string, wsDir?: string): string {
|
|
258
|
+
return substituteWsDir(
|
|
259
|
+
substituteDefaultBranch(BIT_SYNC_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.sync, defaultBranch),
|
|
260
|
+
wsDir
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Render `bit-release.yml` with the repository's actual default branch, and `ws-dir` for a nested workspace. */
|
|
265
|
+
export function renderBitReleaseWorkflow(defaultBranch: string, wsDir?: string): string {
|
|
266
|
+
return substituteWsDir(
|
|
267
|
+
substituteDefaultBranch(BIT_RELEASE_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.release, defaultBranch),
|
|
268
|
+
wsDir
|
|
269
|
+
);
|
|
246
270
|
}
|
|
247
271
|
|
|
248
272
|
export interface ScaffoldFileOutcome {
|
|
@@ -262,11 +286,18 @@ function writeIfAbsent(workspaceDir: string, relativePath: string, content: stri
|
|
|
262
286
|
return { relativePath, status: 'written' };
|
|
263
287
|
}
|
|
264
288
|
|
|
265
|
-
/**
|
|
266
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Scaffold both workflow files under `workspaceDir` (the repository root). `wsDir` is the bit workspace
|
|
291
|
+
* relative to it, when the workspace is a subdirectory. Never overwrites; creates `.github/workflows/`.
|
|
292
|
+
*/
|
|
293
|
+
export function scaffoldWorkflowFiles(
|
|
294
|
+
workspaceDir: string,
|
|
295
|
+
defaultBranch: string,
|
|
296
|
+
wsDir?: string
|
|
297
|
+
): ScaffoldFileOutcome[] {
|
|
267
298
|
return [
|
|
268
|
-
writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.sync, renderBitSyncWorkflow(defaultBranch)),
|
|
269
|
-
writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.release, renderBitReleaseWorkflow(defaultBranch)),
|
|
299
|
+
writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.sync, renderBitSyncWorkflow(defaultBranch, wsDir)),
|
|
300
|
+
writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.release, renderBitReleaseWorkflow(defaultBranch, wsDir)),
|
|
270
301
|
];
|
|
271
302
|
}
|
|
272
303
|
|
|
@@ -294,52 +325,307 @@ export function deriveOwnerRepo(remoteUrl: string | undefined): OwnerRepo | unde
|
|
|
294
325
|
|
|
295
326
|
/**
|
|
296
327
|
* The bit.cloud custom webhook payload template, verified byte-perfect against a live delivery.
|
|
297
|
-
* Lane vs. main export is discriminated by whether `laneId` is empty.
|
|
328
|
+
* Lane vs. main export is discriminated by whether `laneId` is empty. Used on GitHub Enterprise
|
|
329
|
+
* Server, where the webhook posts straight to the repository's `dispatches` API with a token.
|
|
298
330
|
*/
|
|
299
331
|
const WEBHOOK_PAYLOAD_TEMPLATE =
|
|
300
332
|
'{"event_type":"bit-export","client_payload":{"laneId":"{{laneId}}","componentIds":"{{componentIds}}","owner":"{{owner}}","actor":"{{username}}"}}';
|
|
301
333
|
|
|
302
334
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
335
|
+
* On github.com, instant Bit -> Git goes through the hosted relay: the `bit-git-sync` GitHub App lets
|
|
336
|
+
* it send `repository_dispatch` to the repositories it is installed on, and its setup page hands out
|
|
337
|
+
* one webhook URL per repository. No personal token is involved.
|
|
305
338
|
*/
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
339
|
+
const BIT_GIT_SYNC_APP_URL = 'https://github.com/apps/bit-git-sync';
|
|
340
|
+
const WEBHOOK_RELAY_SETUP_URL = 'https://webhook-relay-gggmal.r2.composed.app/setup';
|
|
341
|
+
const SETUP_GUIDE_URL = 'https://github.com/teambit/acme-modular-store#set-it-up-for-your-own-scope';
|
|
342
|
+
|
|
343
|
+
/** What each scaffolded file does, in the words the report shows next to it. */
|
|
344
|
+
const FILE_PURPOSE: Record<string, string> = {
|
|
345
|
+
[WORKFLOW_RELATIVE_PATHS.sync]: 'syncs lanes and branches: on export, on push, and every 30 minutes',
|
|
346
|
+
[WORKFLOW_RELATIVE_PATHS.release]: 'releases the scope when a pull request merges',
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
export interface InitReportInput {
|
|
350
|
+
/** the workspace's default scope, the one this repository mirrors */
|
|
351
|
+
scopeId?: string;
|
|
352
|
+
/** derived from the `origin` remote; undefined when there is none, or it isn't a recognized GitHub host */
|
|
353
|
+
ownerRepo?: OwnerRepo;
|
|
354
|
+
/** the raw `origin` URL, to name a remote that exists but isn't recognized as GitHub */
|
|
355
|
+
originUrl?: string;
|
|
356
|
+
defaultBranch: string;
|
|
357
|
+
/** true when the remote didn't name its default branch and `defaultBranch` is a guess */
|
|
358
|
+
defaultBranchGuessed?: boolean;
|
|
359
|
+
/**
|
|
360
|
+
* The repository root as seen from where the user stands (`.` when they stand in it). The workflows
|
|
361
|
+
* live there, so the first commit and `gh repo create --source` must name it.
|
|
362
|
+
*/
|
|
363
|
+
repoRootFromCwd?: string;
|
|
364
|
+
/**
|
|
365
|
+
* false when the repository has no commit yet. The first commit then takes the whole workspace;
|
|
366
|
+
* after that, the commit takes only the files `--init` touched, never unrelated local changes.
|
|
367
|
+
*/
|
|
368
|
+
repoHasCommits?: boolean;
|
|
369
|
+
/** false when the workspace is not in a git repository yet: step 1 then starts with `git init` */
|
|
370
|
+
inGitRepo?: boolean;
|
|
371
|
+
/** scaffolded workflow files, with the path to show (relative to where the user stands) */
|
|
372
|
+
files: Array<ScaffoldFileOutcome & { displayPath: string }>;
|
|
373
|
+
/** workspace.jsonc, as seen from where the user stands */
|
|
374
|
+
configDisplayPath?: string;
|
|
375
|
+
/** whether `--init` added the sync block to workspace.jsonc, or found one already there */
|
|
376
|
+
configAdded: boolean;
|
|
377
|
+
/**
|
|
378
|
+
* Of the workflow files and workspace.jsonc, the ones git shows as new or changed, as displayed.
|
|
379
|
+
* Omitted: stage them all. Empty: nothing is left to commit (a re-run after the commit).
|
|
380
|
+
*/
|
|
381
|
+
toStage?: string[];
|
|
382
|
+
/**
|
|
383
|
+
* Of those files, the untracked ones `.gitignore` matches, also before the first commit. `git add`
|
|
384
|
+
* refuses them by name and `git add -A` skips them.
|
|
385
|
+
*/
|
|
386
|
+
ignored?: string[];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A remote that is a path on this machine (a bare repository, a `file://` URL), not a server. It has
|
|
391
|
+
* no host to suggest as a GitHub Enterprise Server. Git's own rule: a URL has a scheme, or is scp-like
|
|
392
|
+
* (`host:path`, with no slash before the first colon); anything else is a path, `remote.git` included.
|
|
393
|
+
*/
|
|
394
|
+
export function isLocalPathRemote(remoteUrl: string): boolean {
|
|
395
|
+
const url = remoteUrl.trim();
|
|
396
|
+
if (/^file:\/\//i.test(url)) return true;
|
|
397
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false;
|
|
398
|
+
// a Windows drive (`C:\repos`) looks scp-like to the next check
|
|
399
|
+
if (/^[a-z]:[/\\]/i.test(url)) return true;
|
|
400
|
+
return !/^[^/\\]+:/.test(url);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The remote URL without the credentials a URL can carry (`https://user:token@host/...`), so the
|
|
405
|
+
* report never prints a token. An http(s) user name can itself be a token, so all of it goes; other
|
|
406
|
+
* schemes keep the user name (`ssh://git@host`) and lose only a password.
|
|
407
|
+
*/
|
|
408
|
+
export function redactRemoteUrl(remoteUrl: string): string {
|
|
409
|
+
const url = remoteUrl.trim();
|
|
410
|
+
if (/^https?:\/\//i.test(url)) return url.replace(/^(https?:\/\/)[^@/]*@/i, '$1');
|
|
411
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/[^:@/]*):[^@/]*@/i, '$1@');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** `value` with each control character written as `\xNN`, so printing it cannot move or restyle the terminal. */
|
|
415
|
+
export function escapeControlChars(value: string): string {
|
|
416
|
+
// eslint-disable-next-line no-control-regex
|
|
417
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Quote a value for a POSIX shell, so a command the report prints runs as shown when pasted. Plain
|
|
422
|
+
* names stay unquoted for readability; anything else is single-quoted. A git-valid branch such as
|
|
423
|
+
* `$(id)` must not run as a command substitution.
|
|
424
|
+
*/
|
|
425
|
+
export function shellQuote(value: string): string {
|
|
426
|
+
return /^[\w./@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The default branch `--init` writes into the workflows: the one the remote names, else a guess. The
|
|
431
|
+
* guess is the checked-out branch only when it is `main` or `master`: a feature branch in the branch
|
|
432
|
+
* filters would exclude itself from sync and point releases at the wrong base.
|
|
433
|
+
*/
|
|
434
|
+
export function chooseInitDefaultBranch(detected: string | undefined, current: string | undefined): string {
|
|
435
|
+
if (detected) return detected;
|
|
436
|
+
return current === 'main' || current === 'master' ? current : 'main';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The `GITHUB_SERVER_URL` to suggest for a remote bit doesn't recognize: an http(s) remote's own origin,
|
|
441
|
+
* port included, else https on the remote's host (an ssh port is not the web server's).
|
|
442
|
+
*/
|
|
443
|
+
export function suggestedServerUrl(remoteUrl: string): string {
|
|
444
|
+
if (/^https?:\/\//i.test(remoteUrl.trim())) {
|
|
445
|
+
try {
|
|
446
|
+
return new URL(remoteUrl.trim()).origin;
|
|
447
|
+
} catch {
|
|
448
|
+
// fall through to the host
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return `https://${remoteHost(remoteUrl) ?? '<your-server>'}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The report `bit ci sync --init` prints: what it changed, then the steps that need a person, as
|
|
456
|
+
* commands to paste. Short by design; the setup guide carries the details. Commands are printed only
|
|
457
|
+
* for a resolved repository: a placeholder would not run when pasted.
|
|
458
|
+
*/
|
|
459
|
+
export function renderInitReport(input: InitReportInput): string {
|
|
460
|
+
const { ownerRepo, defaultBranch } = input;
|
|
461
|
+
const scope = input.scopeId ?? '<scope>';
|
|
462
|
+
// An origin that isn't recognized is most often a GitHub Enterprise Server the shell doesn't know about.
|
|
463
|
+
const redactedOrigin = !ownerRepo && input.originUrl ? redactRemoteUrl(input.originUrl) : undefined;
|
|
464
|
+
// Shown, so made inert: a control character (a newline, a terminal escape) must not reach the terminal.
|
|
465
|
+
const unrecognizedOrigin = redactedOrigin && escapeControlChars(redactedOrigin);
|
|
466
|
+
// Suggest a server only for a remote that names a host: a local path has none, and a URL with a
|
|
467
|
+
// control character in it has no trustworthy one.
|
|
468
|
+
const serverHost =
|
|
469
|
+
redactedOrigin && redactedOrigin === unrecognizedOrigin && !isLocalPathRemote(redactedOrigin)
|
|
470
|
+
? remoteHost(redactedOrigin)
|
|
471
|
+
: undefined;
|
|
472
|
+
const repoRootFromCwd = input.repoRootFromCwd || '.';
|
|
473
|
+
const configDisplayPath = input.configDisplayPath || 'workspace.jsonc';
|
|
474
|
+
|
|
475
|
+
const target = ownerRepo
|
|
476
|
+
? chalk.cyan(
|
|
477
|
+
ownerRepo.host === 'github.com'
|
|
478
|
+
? `${ownerRepo.owner}/${ownerRepo.repo}`
|
|
479
|
+
: `${ownerRepo.host}/${ownerRepo.owner}/${ownerRepo.repo}`
|
|
480
|
+
)
|
|
481
|
+
: unrecognizedOrigin
|
|
482
|
+
? `origin ${unrecognizedOrigin}`
|
|
483
|
+
: 'no GitHub remote yet';
|
|
484
|
+
const header = [
|
|
485
|
+
`${successSymbol()} ${formatTitle('Scaffolded bit ci sync')} for ${chalk.cyan(scope)}, mirrored to ${target} ${formatHint(
|
|
486
|
+
`(default branch: ${defaultBranch})`
|
|
487
|
+
)}`,
|
|
488
|
+
];
|
|
489
|
+
if (input.defaultBranchGuessed) {
|
|
490
|
+
const why = input.originUrl ? "the remote didn't name its default branch" : 'no remote names the default branch';
|
|
491
|
+
header.push(
|
|
492
|
+
formatWarningSummary(
|
|
493
|
+
`${why}, so the workflows use "${defaultBranch}". If that's wrong,\n` +
|
|
494
|
+
` edit the branch filters in both workflow files.`
|
|
495
|
+
)
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (serverHost && unrecognizedOrigin) {
|
|
499
|
+
header.push(
|
|
500
|
+
formatWarningSummary("origin isn't a GitHub remote bit recognizes. For GitHub Enterprise Server, run:")
|
|
501
|
+
);
|
|
502
|
+
header.push(
|
|
503
|
+
` ${chalk.cyan(`GITHUB_SERVER_URL=${shellQuote(suggestedServerUrl(unrecognizedOrigin))} bit ci sync --init`)}`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const width = Math.max(...input.files.map((f) => f.displayPath.length), configDisplayPath.length) + 2;
|
|
508
|
+
const fileItem = (displayPath: string, note: string) => formatItem(`${displayPath.padEnd(width)}${formatHint(note)}`);
|
|
509
|
+
const files = [
|
|
510
|
+
...input.files.map((file) =>
|
|
511
|
+
fileItem(
|
|
512
|
+
file.displayPath,
|
|
513
|
+
file.status === 'written' ? (FILE_PURPOSE[file.relativePath] ?? '') : 'already exists, left unchanged'
|
|
514
|
+
)
|
|
515
|
+
),
|
|
516
|
+
fileItem(
|
|
517
|
+
configDisplayPath,
|
|
518
|
+
input.configAdded ? 'added "teambit.git/ci": { "sync": {} }' : 'already configures sync, left unchanged'
|
|
519
|
+
),
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
const step = (n: number, title: string, who: string, body: string[]) =>
|
|
523
|
+
[`${formatTitle(`${n}. ${title}`)} ${formatHint(`(${who})`)}`, ...body.map((line) => ` ${line}`)].join('\n');
|
|
524
|
+
const cmd = (text: string) => chalk.cyan(text);
|
|
525
|
+
// Stage and commit exactly what git shows as new or changed among the files `--init` handles, so
|
|
526
|
+
// unrelated local changes stay out: a re-run still stages files that were never committed, and a
|
|
527
|
+
// file already committed is not named. The first commit of a new repository is the exception: it
|
|
528
|
+
// must carry the whole workspace. `:/` stages from the repository root, where the workflows are,
|
|
529
|
+
// when the workspace is a subdirectory.
|
|
530
|
+
const firstCommit = input.repoHasCommits === false;
|
|
531
|
+
const toStage = firstCommit ? [] : (input.toStage ?? [...input.files.map((f) => f.displayPath), configDisplayPath]);
|
|
532
|
+
const ignored = input.ignored ?? [];
|
|
533
|
+
const quoted = (paths: string[]) => paths.map(shellQuote).join(' ');
|
|
534
|
+
const stage: string[] = [
|
|
535
|
+
...(firstCommit ? [cmd(repoRootFromCwd === '.' ? 'git add -A' : 'git add -A :/')] : []),
|
|
536
|
+
...(toStage.length ? [cmd(`git add -- ${quoted(toStage)}`)] : []),
|
|
537
|
+
// `git add` skips or refuses a path that .gitignore matches, and GitHub runs only committed workflows
|
|
538
|
+
...(ignored.length
|
|
539
|
+
? [cmd(`git add -f -- ${quoted(ignored)}`), formatHint('.gitignore matches these; -f adds them anyway')]
|
|
540
|
+
: []),
|
|
541
|
+
];
|
|
542
|
+
// Commit and push are separate: on a re-run nothing may be left to commit, and the push must still run.
|
|
543
|
+
// Naming the paths keeps changes the user staged before out of the commit.
|
|
544
|
+
// `-b`: without init.defaultBranch, a bare `git init` makes `master`, a branch the workflows ignore
|
|
545
|
+
const gitInit = `git init -b ${shellQuote(defaultBranch)}${repoRootFromCwd === '.' ? '' : ` ${shellQuote(repoRootFromCwd)}`}`;
|
|
546
|
+
const toCommit = [...toStage, ...ignored];
|
|
547
|
+
const nothingToCommit = !firstCommit && !toCommit.length;
|
|
548
|
+
const commitMessage = `git commit -m ${shellQuote(`Mirror ${scope}`)}`;
|
|
549
|
+
const commit = [
|
|
550
|
+
...(input.inGitRepo === false ? [cmd(gitInit)] : []),
|
|
551
|
+
...stage,
|
|
552
|
+
...(nothingToCommit
|
|
553
|
+
? [formatHint('nothing to commit: these files are already committed')]
|
|
554
|
+
: firstCommit
|
|
555
|
+
? [cmd(commitMessage)]
|
|
556
|
+
: // the paths go on a continuation line, as in the permissions command, to keep lines short
|
|
557
|
+
[cmd(`${commitMessage} -- \\`), cmd(` ${quoted(toCommit)}`)]),
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
const steps: string[] = [];
|
|
561
|
+
if (!ownerRepo) {
|
|
562
|
+
steps.push(
|
|
563
|
+
unrecognizedOrigin
|
|
564
|
+
? step(1, 'Push it', 'you', [...commit, cmd('git push -u origin HEAD')])
|
|
565
|
+
: step(1, 'Create the repository and push it', 'you', [
|
|
566
|
+
...commit,
|
|
567
|
+
cmd(`gh repo create --private --source ${shellQuote(repoRootFromCwd)} --remote origin --push`),
|
|
568
|
+
formatHint('names the repository after its root folder; add OWNER/NAME after "create" to choose'),
|
|
569
|
+
]),
|
|
570
|
+
step(2, 'Run bit ci sync --init again', 'you', [
|
|
571
|
+
'Once origin is a GitHub remote bit recognizes, it prints the secret, permission and webhook',
|
|
572
|
+
'steps for your repository as commands.',
|
|
573
|
+
])
|
|
574
|
+
);
|
|
575
|
+
} else {
|
|
576
|
+
const onGitHubCom = ownerRepo.host === 'github.com';
|
|
577
|
+
const repo = `${ownerRepo.owner}/${ownerRepo.repo}`;
|
|
578
|
+
// gh takes HOST/OWNER/REPO, or --hostname, for an enterprise server
|
|
579
|
+
const ghRepo = shellQuote(onGitHubCom ? repo : `${ownerRepo.host}/${repo}`);
|
|
580
|
+
const ghApi = onGitHubCom ? 'gh api' : `gh api --hostname ${shellQuote(ownerRepo.host)}`;
|
|
581
|
+
steps.push(
|
|
582
|
+
step(1, 'Push it', 'you', [...commit, cmd('git push -u origin HEAD')]),
|
|
583
|
+
step(2, 'Let the workflows publish to bit.cloud and open pull requests', 'repository admin', [
|
|
584
|
+
cmd(`gh secret set BIT_CONFIG_ACCESS_TOKEN -R ${ghRepo}`),
|
|
585
|
+
formatHint(`a bit.cloud access token with Write permission on ${scope}. If your organization`),
|
|
586
|
+
formatHint("requires change requests, exempt the token's account: it is the one that merges lanes."),
|
|
587
|
+
cmd(`${ghApi} -X PUT ${shellQuote(`repos/${repo}/actions/permissions/workflow`)} \\`),
|
|
588
|
+
cmd(' -f default_workflow_permissions=write -F can_approve_pull_request_reviews=true'),
|
|
589
|
+
formatHint(
|
|
590
|
+
'turns on "Read and write permissions" and "Allow GitHub Actions to create and approve pull requests".'
|
|
591
|
+
),
|
|
592
|
+
formatHint('If they are greyed out in the settings, an organization owner must allow them first.'),
|
|
593
|
+
cmd(`gh secret set BIT_SYNC_GH_TOKEN -R ${ghRepo}`),
|
|
594
|
+
formatHint('optional: a GitHub token, so the pushes the sync makes start your other workflows (CI checks)'),
|
|
595
|
+
])
|
|
596
|
+
);
|
|
597
|
+
if (onGitHubCom) {
|
|
598
|
+
steps.push(
|
|
599
|
+
step(3, 'Make Bit -> Git instant', 'organization admin, optional', [
|
|
600
|
+
`Install ${chalk.cyan(BIT_GIT_SYNC_APP_URL)} on ${repo}.`,
|
|
601
|
+
`Copy the repository's webhook URL from ${chalk.cyan(WEBHOOK_RELAY_SETUP_URL)}`,
|
|
602
|
+
`and add it to ${scope} on bit.cloud as an "Export succeeded" webhook.`,
|
|
603
|
+
])
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
const api = gitHubApiUrl(ownerRepo.host);
|
|
607
|
+
const dispatchUrl = `${'error' in api ? '<GitHub API URL>' : api.url}/repos/${repo}/dispatches`;
|
|
608
|
+
steps.push(
|
|
609
|
+
step(3, 'Make Bit -> Git instant', 'optional', [
|
|
610
|
+
`On bit.cloud, add an "Export succeeded" webhook to ${scope}:`,
|
|
611
|
+
` URL ${chalk.cyan(dispatchUrl)}`,
|
|
612
|
+
...('error' in api ? [chalk.yellow(` (${api.error})`)] : []),
|
|
613
|
+
' Headers Authorization: Bearer <token with access to this repository>',
|
|
614
|
+
' Accept: application/vnd.github+json',
|
|
615
|
+
` Payload ${WEBHOOK_PAYLOAD_TEMPLATE}`,
|
|
616
|
+
formatHint('Create it fresh: editing a bit.cloud webhook drops its custom headers.'),
|
|
617
|
+
])
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const footer = ownerRepo
|
|
623
|
+
? `After step 2 the sync runs on pushes to feature branches and every 30 minutes; step 3 adds instant Bit -> Git.\nGuide: ${SETUP_GUIDE_URL}`
|
|
624
|
+
: `Guide: ${SETUP_GUIDE_URL}`;
|
|
625
|
+
return joinSections([
|
|
626
|
+
header.join('\n'),
|
|
627
|
+
files.join('\n'),
|
|
628
|
+
[formatTitle(`Next: ${steps.length} steps to go live`), '', steps.join('\n\n')].join('\n'),
|
|
629
|
+
formatHint(footer),
|
|
630
|
+
]);
|
|
345
631
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatTitle, formatWarningSummary } from '@teambit/cli';
|
|
2
|
+
import fs from 'fs';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import { BitError } from '@teambit/bit-error';
|
|
4
5
|
import type { Logger } from '@teambit/logger';
|
|
@@ -11,6 +12,7 @@ import { git } from '../git';
|
|
|
11
12
|
import type { CiSyncConfig } from './sync-config';
|
|
12
13
|
import {
|
|
13
14
|
branchToLaneName,
|
|
15
|
+
isValidGitBranchName,
|
|
14
16
|
laneNameToBranch,
|
|
15
17
|
parseLaneTarget,
|
|
16
18
|
resolveSyncConfig,
|
|
@@ -21,7 +23,7 @@ import { HALT_SUMMARY_PREFIX, REFUSED_SUMMARY_PREFIX, LaneSyncExecutor } from '.
|
|
|
21
23
|
import { MainSyncExecutor } from './main-sync-executor';
|
|
22
24
|
import { selectGitHostProvider } from './git-host-provider';
|
|
23
25
|
import { gitRepoRoot, isNonContentPath, listRemoteBranches } from './git-ops';
|
|
24
|
-
import { deriveOwnerRepo,
|
|
26
|
+
import { chooseInitDefaultBranch, deriveOwnerRepo, renderInitReport, scaffoldWorkflowFiles } from './init-scaffold';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Refuse a `--dry-run` whose plan would be computed by force-checking-out branches over uncommitted
|
|
@@ -207,11 +209,17 @@ export class SyncOrchestrator {
|
|
|
207
209
|
|
|
208
210
|
/**
|
|
209
211
|
* `bit ci sync --init` — one-command onboarding: scaffolds the two workflow files, adds the sync
|
|
210
|
-
* config block to workspace.jsonc if missing, and
|
|
212
|
+
* config block to workspace.jsonc if missing, and returns the report of the steps left. Safe to
|
|
211
213
|
* re-run: existing files are skipped, an existing config block is left alone; it never reconciles.
|
|
212
214
|
*/
|
|
213
215
|
private async syncInit(): Promise<string> {
|
|
214
|
-
const
|
|
216
|
+
const remoteUrl = await git.remote(['get-url', 'origin']).catch(() => undefined);
|
|
217
|
+
const origin = typeof remoteUrl === 'string' ? remoteUrl.trim() : undefined;
|
|
218
|
+
// The remote knows the default branch. When it can't answer (no origin, a repository not pushed
|
|
219
|
+
// yet, a server out of reach), guess: the checked-out branch when it's main or master, else main.
|
|
220
|
+
// A feature branch is never the guess: it would go into the workflows' branch filters.
|
|
221
|
+
const detectedBranch = await this.deps.ci.detectDefaultBranchName();
|
|
222
|
+
const defaultBranch = chooseInitDefaultBranch(detectedBranch, await this.currentBranchOr(''));
|
|
215
223
|
|
|
216
224
|
// Workflows belong to the repository, not the workspace: GitHub only discovers them at
|
|
217
225
|
// `<repo-root>/.github/workflows`, and a bit workspace may be a subdirectory.
|
|
@@ -225,30 +233,89 @@ export class SyncOrchestrator {
|
|
|
225
233
|
);
|
|
226
234
|
}
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
const
|
|
231
|
-
|
|
236
|
+
// A workspace below the repository root: the actions must chdir into it (`ws-dir`).
|
|
237
|
+
// Real paths: git reports the root with symlinks resolved (macOS `/tmp` is `/private/tmp`).
|
|
238
|
+
const realPath = (p: string) => (fs.existsSync(p) ? fs.realpathSync(p) : p);
|
|
239
|
+
const wsDir = path.relative(realPath(scaffoldRoot), realPath(this.deps.workspace.path)).split(path.sep).join('/');
|
|
240
|
+
const fileOutcomes = scaffoldWorkflowFiles(
|
|
241
|
+
scaffoldRoot,
|
|
242
|
+
defaultBranch,
|
|
243
|
+
wsDir && !wsDir.startsWith('..') ? wsDir : undefined
|
|
244
|
+
);
|
|
245
|
+
// Report the path relative to where the user is standing (`../../.github/...` from a nested
|
|
246
|
+
// workspace), not to the workspace, so it is right to paste. Absolute only across drives.
|
|
247
|
+
const displayPath = (abs: string) => {
|
|
232
248
|
const fromCwd = path.relative(process.cwd(), abs);
|
|
233
|
-
return fromCwd && !
|
|
249
|
+
return fromCwd && !path.isAbsolute(fromCwd) ? fromCwd : abs;
|
|
234
250
|
};
|
|
235
|
-
const fileLines = fileOutcomes.map((outcome) =>
|
|
236
|
-
outcome.status === 'written'
|
|
237
|
-
? `wrote ${displayPath(outcome.relativePath)} (default branch: ${defaultBranch})`
|
|
238
|
-
: `skipped ${displayPath(outcome.relativePath)} (already exists — bit ci sync --init never overwrites)`
|
|
239
|
-
);
|
|
240
|
-
|
|
241
251
|
const configAdded = await this.ensureSyncConfigBlock();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
252
|
+
// No commit yet: the first commit must take the whole workspace, not only the files --init wrote.
|
|
253
|
+
const repoHasCommits = await git
|
|
254
|
+
.raw(['rev-parse', '--verify', '--quiet', 'HEAD'])
|
|
255
|
+
.then((out) => out.trim().length > 0)
|
|
256
|
+
.catch(() => false);
|
|
245
257
|
|
|
246
|
-
|
|
247
|
-
|
|
258
|
+
// What step 1 must stage: of the files --init handles, those git shows as new or changed. One that
|
|
259
|
+
// .gitignore matches is listed apart, because `git add` refuses an ignored path named on the command line.
|
|
260
|
+
const handled = [
|
|
261
|
+
...fileOutcomes.map((outcome) => path.join(scaffoldRoot, outcome.relativePath)),
|
|
262
|
+
path.join(this.deps.workspace.path, 'workspace.jsonc'),
|
|
263
|
+
];
|
|
264
|
+
const toStage: string[] = [];
|
|
265
|
+
const ignored: string[] = [];
|
|
266
|
+
if (repoRoot) {
|
|
267
|
+
for (const abs of handled) {
|
|
268
|
+
// Ignored first: `git status` hides an ignored file, so it would look unchanged. `check-ignore`
|
|
269
|
+
// skips tracked files and prints the path only when it is ignored (it exits 1 otherwise). It
|
|
270
|
+
// needs no commit, and the first commit's `git add -A` skips an ignored file too.
|
|
271
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
272
|
+
const isIgnored = await git
|
|
273
|
+
.raw(['check-ignore', '--', abs])
|
|
274
|
+
.then((out) => out.trim().length > 0)
|
|
275
|
+
.catch(() => false);
|
|
276
|
+
if (isIgnored) {
|
|
277
|
+
ignored.push(displayPath(abs));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// the first commit stages everything with `git add -A`
|
|
281
|
+
if (!repoHasCommits) continue;
|
|
282
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
283
|
+
const changed = await git
|
|
284
|
+
.raw(['status', '--porcelain', '--untracked-files=all', '--', abs])
|
|
285
|
+
.then((out) => out.trim().length > 0)
|
|
286
|
+
.catch(() => true);
|
|
287
|
+
if (changed) toStage.push(displayPath(abs));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
248
290
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
291
|
+
// Returned, not logged: the command prints what `report()` returns.
|
|
292
|
+
return renderInitReport({
|
|
293
|
+
scopeId: this.deps.workspace.defaultScope,
|
|
294
|
+
ownerRepo: deriveOwnerRepo(origin),
|
|
295
|
+
originUrl: origin,
|
|
296
|
+
defaultBranch,
|
|
297
|
+
defaultBranchGuessed: !detectedBranch,
|
|
298
|
+
// the workflows live at the repository root, which may be above the workspace
|
|
299
|
+
repoRootFromCwd: path.relative(process.cwd(), scaffoldRoot) || '.',
|
|
300
|
+
repoHasCommits,
|
|
301
|
+
inGitRepo: Boolean(repoRoot),
|
|
302
|
+
files: fileOutcomes.map((outcome) => ({
|
|
303
|
+
...outcome,
|
|
304
|
+
displayPath: displayPath(path.join(scaffoldRoot, outcome.relativePath)),
|
|
305
|
+
})),
|
|
306
|
+
configDisplayPath: displayPath(path.join(this.deps.workspace.path, 'workspace.jsonc')),
|
|
307
|
+
configAdded,
|
|
308
|
+
...(repoRoot ? { toStage, ignored } : {}),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The checked-out branch when it is a usable name (not detached, not option-like), else `fallback`. */
|
|
313
|
+
private async currentBranchOr(fallback: string): Promise<string> {
|
|
314
|
+
const current = await git
|
|
315
|
+
.raw(['branch', '--show-current'])
|
|
316
|
+
.then((out) => out.trim())
|
|
317
|
+
.catch(() => '');
|
|
318
|
+
return current && isValidGitBranchName(current) ? current : fallback;
|
|
252
319
|
}
|
|
253
320
|
|
|
254
321
|
/**
|