@teambit/ci 1.0.637 → 1.0.639

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.
@@ -9,7 +9,13 @@ import {
9
9
  renderBitReleaseWorkflow,
10
10
  scaffoldWorkflowFiles,
11
11
  deriveOwnerRepo,
12
- renderInitChecklist,
12
+ renderInitReport,
13
+ shellQuote,
14
+ chooseInitDefaultBranch,
15
+ suggestedServerUrl,
16
+ isLocalPathRemote,
17
+ redactRemoteUrl,
18
+ escapeControlChars,
13
19
  } from './init-scaffold';
14
20
  import type { OwnerRepo } from './init-scaffold';
15
21
 
@@ -56,6 +62,20 @@ describe('init-scaffold', () => {
56
62
  expect(renderBitSyncWorkflow("it's/a-branch")).to.contain("'it''s/a-branch'");
57
63
  expect(yamlLoad(renderBitSyncWorkflow('release/main')).jobs).to.be.an('object');
58
64
  });
65
+
66
+ it('set the action ws-dir for a workspace below the repository root, and leave it commented at the root', () => {
67
+ const withInput = (doc: string) =>
68
+ Object.values(yamlLoad(doc).jobs as Record<string, any>)
69
+ .flatMap((job) => job.steps)
70
+ .find((s: any) => String(s.uses).startsWith('teambit/bit-git-sync')).with;
71
+ [renderBitSyncWorkflow, renderBitReleaseWorkflow].forEach((render) => {
72
+ expect(withInput(render('main', 'packages/app'))['ws-dir']).to.equal('packages/app');
73
+ expect(withInput(render('main', "it's dir"))['ws-dir']).to.equal("it's dir");
74
+ expect(withInput(render('main'))).to.not.have.property('ws-dir');
75
+ expect(render('main')).to.contain('# ws-dir: packages/my-workspace');
76
+ expect(render('main', '.')).to.equal(render('main'));
77
+ });
78
+ });
59
79
  });
60
80
 
61
81
  describe('scaffoldWorkflowFiles', () => {
@@ -117,33 +137,281 @@ describe('init-scaffold', () => {
117
137
  });
118
138
  });
119
139
 
120
- describe('renderInitChecklist', () => {
121
- it('renders a real dispatch URL when owner/repo were derived, a placeholder when not', () => {
122
- const derived = renderInitChecklist({ owner: 'acme', repo: 'shop', host: 'github.com' });
123
- expect(derived).to.contain('https://api.github.com/repos/acme/shop/dispatches');
124
- expect(derived).to.not.contain('<owner>/<repo>');
125
- expect(renderInitChecklist(undefined)).to.contain('https://api.github.com/repos/<owner>/<repo>/dispatches');
126
- });
127
-
128
- it('derives the repository and the dispatch URL from a GitHub Enterprise Server remote', () => {
129
- const saved = { api: process.env.GITHUB_API_URL, server: process.env.GITHUB_SERVER_URL };
130
- process.env.GITHUB_API_URL = 'https://ghe.corp.example/api/v3';
131
- process.env.GITHUB_SERVER_URL = 'https://ghe.corp.example';
132
- try {
133
- const ownerRepo = deriveOwnerRepo('git@ghe.corp.example:acme/store.git');
134
- expect(ownerRepo).to.deep.equal({ owner: 'acme', repo: 'store', host: 'ghe.corp.example' });
135
- expect(renderInitChecklist(ownerRepo)).to.contain(
136
- 'https://ghe.corp.example/api/v3/repos/acme/store/dispatches'
137
- );
138
- } finally {
139
- if (saved.api === undefined) delete process.env.GITHUB_API_URL;
140
- else process.env.GITHUB_API_URL = saved.api;
141
- if (saved.server === undefined) delete process.env.GITHUB_SERVER_URL;
142
- else process.env.GITHUB_SERVER_URL = saved.server;
143
- }
140
+ describe('chooseInitDefaultBranch', () => {
141
+ it('takes the detected branch, else main or master when checked out, else main', () => {
142
+ expect(chooseInitDefaultBranch('trunk', 'feature/x')).to.equal('trunk');
143
+ expect(chooseInitDefaultBranch(undefined, 'master')).to.equal('master');
144
+ expect(chooseInitDefaultBranch(undefined, 'main')).to.equal('main');
145
+ expect(chooseInitDefaultBranch(undefined, 'feature/x')).to.equal('main');
146
+ expect(chooseInitDefaultBranch(undefined, undefined)).to.equal('main');
147
+ });
148
+ });
149
+
150
+ describe('renderInitReport', () => {
151
+ const files = [
152
+ {
153
+ relativePath: WORKFLOW_RELATIVE_PATHS.sync,
154
+ status: 'written' as const,
155
+ displayPath: '.github/workflows/bit-sync.yml',
156
+ },
157
+ {
158
+ relativePath: WORKFLOW_RELATIVE_PATHS.release,
159
+ status: 'written' as const,
160
+ displayPath: '.github/workflows/bit-release.yml',
161
+ },
162
+ ];
163
+ // eslint-disable-next-line no-control-regex
164
+ const plain = (text: string) => text.replace(/\u001b\[[0-9;]*m/g, '');
165
+ const report = (remote: string | undefined, extra: Partial<Parameters<typeof renderInitReport>[0]> = {}) =>
166
+ plain(
167
+ renderInitReport({
168
+ scopeId: 'acme.shop',
169
+ ownerRepo: deriveOwnerRepo(remote),
170
+ defaultBranch: 'main',
171
+ files,
172
+ configAdded: true,
173
+ ...extra,
174
+ })
175
+ );
176
+
177
+ it('prints each step once, as commands for the derived repository', () => {
178
+ const out = report('git@github.com:acme/shop.git');
179
+ expect(out).to.contain('for acme.shop, mirrored to acme/shop (default branch: main)');
180
+ expect(out).to.contain('git push -u origin HEAD');
181
+ expect(out).to.contain('gh secret set BIT_CONFIG_ACCESS_TOKEN -R acme/shop');
182
+ expect(out).to.contain('gh api -X PUT repos/acme/shop/actions/permissions/workflow');
183
+ expect(out.match(/Next: 3 steps to go live/g)).to.have.length(1);
184
+ });
185
+
186
+ it('points github.com repositories at the GitHub App and the relay, not at a personal token', () => {
187
+ const out = report('https://github.com/acme/shop.git');
188
+ expect(out).to.contain('https://github.com/apps/bit-git-sync');
189
+ expect(out).to.contain('/setup');
190
+ expect(out).to.not.contain('/dispatches');
191
+ expect(out).to.not.contain('Authorization: Bearer');
192
+ });
193
+
194
+ it('prints no placeholder commands when there is no GitHub remote yet', () => {
195
+ const out = report(undefined);
196
+ expect(out).to.contain('no GitHub remote yet');
197
+ expect(out).to.contain('gh repo create --private --source . --remote origin --push');
198
+ expect(out).to.not.contain('GITHUB_SERVER_URL');
199
+ expect(out).to.contain('Run bit ci sync --init again');
200
+ expect(out).to.contain('Next: 2 steps to go live');
201
+ expect(out).to.not.contain('<owner>');
202
+ expect(out).to.not.contain('gh secret set');
203
+ });
204
+
205
+ it('commits and pushes in separate commands, so a re-run with nothing to commit still pushes', () => {
206
+ const out = report('git@github.com:acme/shop.git');
207
+ expect(out).to.match(/git commit -m 'Mirror acme\.shop' -- \\\n\s+[^\n]+\n\s+git push -u origin HEAD/);
208
+ expect(out).to.not.contain('&& git');
209
+ });
210
+
211
+ it('stages only the files --init touched, so unrelated local changes stay out of the commit', () => {
212
+ const out = report('git@github.com:acme/shop.git');
213
+ expect(out).to.contain(
214
+ 'git add -- .github/workflows/bit-sync.yml .github/workflows/bit-release.yml workspace.jsonc\n'
215
+ );
216
+ expect(out).to.not.contain('git add -A');
217
+ // the paths are the ones shown, as seen from where the user stands
218
+ expect(
219
+ report('git@github.com:acme/shop.git', {
220
+ files: files.map((f) => ({ ...f, displayPath: `/repo root/${f.displayPath}` })),
221
+ configDisplayPath: 'workspace.jsonc',
222
+ })
223
+ ).to.contain("git add -- '/repo root/.github/workflows/bit-sync.yml'");
224
+ });
225
+
226
+ it('stages exactly the files git shows as new or changed', () => {
227
+ const out = report('git@github.com:acme/shop.git', { toStage: ['.github/workflows/bit-sync.yml'], ignored: [] });
228
+ expect(out).to.contain('git add -- .github/workflows/bit-sync.yml\n');
229
+ expect(out).to.not.contain('bit-release.yml workspace.jsonc');
230
+ });
231
+
232
+ it('force-adds a workflow file that .gitignore matches, so the rest of the staging still runs', () => {
233
+ const out = report('git@github.com:acme/shop.git', {
234
+ toStage: ['workspace.jsonc'],
235
+ ignored: ['.github/workflows/bit-release.yml'],
236
+ });
237
+ expect(out).to.contain('git add -- workspace.jsonc\n');
238
+ expect(out).to.contain('git add -f -- .github/workflows/bit-release.yml\n');
239
+ expect(out).to.contain('.gitignore matches these');
240
+ expect(out).to.contain("git commit -m 'Mirror acme.shop'");
241
+ });
242
+
243
+ it('commits only the files it stages, so changes staged before stay out of the commit', () => {
244
+ const out = report('git@github.com:acme/shop.git', {
245
+ toStage: ['workspace.jsonc'],
246
+ ignored: ['.github/workflows/bit-release.yml'],
247
+ });
248
+ expect(out).to.match(
249
+ /git commit -m 'Mirror acme\.shop' -- \\\n\s+workspace\.jsonc \.github\/workflows\/bit-release\.yml\n/
250
+ );
251
+ });
252
+
253
+ it('force-adds an ignored workflow file also for the first commit, which git add -A would skip', () => {
254
+ const out = report(undefined, { repoHasCommits: false, ignored: ['.github/workflows/bit-sync.yml'] });
255
+ expect(out).to.match(/git add -A\n\s+git add -f -- \.github\/workflows\/bit-sync\.yml\n/);
256
+ // the first commit takes the whole workspace, so it names no paths
257
+ expect(out).to.match(/git commit -m 'Mirror acme\.shop'\n/);
258
+ });
259
+
260
+ it('prints no commit when nothing is left to commit, and still pushes', () => {
261
+ const out = report('git@github.com:acme/shop.git', { toStage: [], ignored: [] });
262
+ expect(out).to.not.contain('git add');
263
+ expect(out).to.not.contain('git commit');
264
+ expect(out).to.contain('nothing to commit');
265
+ expect(out).to.contain('git push -u origin HEAD');
144
266
  });
145
267
 
146
- describe('on a GHES-configured runner', () => {
268
+ it('starts with git init when the workspace is not in a git repository', () => {
269
+ const out = report(undefined, { inGitRepo: false, repoHasCommits: false });
270
+ expect(out).to.match(/git init -b main\n\s+git add -A\n\s+git commit -m/);
271
+ expect(report(undefined, { inGitRepo: false, repoRootFromCwd: '..' })).to.contain('git init -b main ..\n');
272
+ // the branch the workflows use, not git's own default (master when init.defaultBranch is unset)
273
+ expect(report(undefined, { inGitRepo: false, defaultBranch: 'trunk' })).to.contain('git init -b trunk\n');
274
+ expect(report(undefined)).to.not.contain('git init');
275
+ });
276
+
277
+ it('stages the whole workspace for the first commit of a new repository', () => {
278
+ expect(report(undefined, { repoHasCommits: false })).to.contain('git add -A\n');
279
+ expect(report(undefined, { repoHasCommits: false, repoRootFromCwd: '../..' })).to.contain('git add -A :/\n');
280
+ });
281
+
282
+ it('shell-quotes values it puts in commands', () => {
283
+ expect(shellQuote('main')).to.equal('main');
284
+ expect(shellQuote('feature/x-1.2')).to.equal('feature/x-1.2');
285
+ expect(shellQuote('$(id)')).to.equal("'$(id)'");
286
+ expect(shellQuote("it's")).to.equal("'it'\\''s'");
287
+ // the push names HEAD, so a branch name never reaches a command
288
+ expect(report('git@github.com:acme/shop.git', { defaultBranch: '$(id)' })).to.contain('git push -u origin HEAD');
289
+ });
290
+
291
+ it('names the token-account exemption, the PR setting and the optional CI token', () => {
292
+ const out = report('git@github.com:acme/shop.git');
293
+ expect(out).to.contain('exempt the token');
294
+ expect(out).to.contain('Allow GitHub Actions to create and approve pull requests');
295
+ // one command sets both settings guide step 4 names
296
+ expect(out).to.contain('-f default_workflow_permissions=write -F can_approve_pull_request_reviews=true');
297
+ expect(out).to.contain('Read and write permissions');
298
+ // the command line ends at the command, so it runs when pasted
299
+ expect(out).to.match(/gh secret set BIT_SYNC_GH_TOKEN -R acme\/shop\n\s+optional: a GitHub token/);
300
+ });
301
+
302
+ it('states the schedule the scaffolded workflow installs', () => {
303
+ expect(renderBitSyncWorkflow('main')).to.contain("cron: '*/30 * * * *'");
304
+ expect(report('git@github.com:acme/shop.git')).to.contain('every 30 minutes');
305
+ });
306
+
307
+ it('names an origin it does not recognize, and says how to mark a GitHub Enterprise Server', () => {
308
+ const origin = 'git@github.corp.example:acme/store.git';
309
+ const out = report(origin, { originUrl: origin });
310
+ expect(out).to.contain(`origin ${origin}`);
311
+ expect(out).to.contain('GITHUB_SERVER_URL=https://github.corp.example bit ci sync --init');
312
+ expect(out).to.contain('git push -u origin HEAD');
313
+ expect(out).to.not.contain('no GitHub remote yet');
314
+ // the host is unknown, so no github.com steps
315
+ expect(out).to.not.contain('github.com/apps/bit-git-sync');
316
+ expect(out).to.not.contain('gh secret set');
317
+ });
318
+
319
+ it('marks files that already existed and a config block that was already there', () => {
320
+ const out = report('git@github.com:acme/shop.git', {
321
+ files: [{ ...files[0], status: 'skipped' }, files[1]],
322
+ configAdded: false,
323
+ });
324
+ expect(out).to.match(/\.github\/workflows\/bit-sync\.yml\s+already exists, left unchanged/);
325
+ expect(out).to.match(/\.github\/workflows\/bit-release\.yml\s+releases the scope/);
326
+ expect(out).to.match(/workspace\.jsonc\s+already configures sync/);
327
+ });
328
+
329
+ it('creates the repository from the repository root when the workspace is a subdirectory', () => {
330
+ expect(report(undefined, { repoRootFromCwd: '../..' })).to.contain(
331
+ 'gh repo create --private --source ../.. --remote origin --push'
332
+ );
333
+ expect(report(undefined, { repoRootFromCwd: '/my repo' })).to.contain("--source '/my repo' --remote");
334
+ });
335
+
336
+ it('flags a guessed default branch', () => {
337
+ const origin = 'git@github.com:acme/shop.git';
338
+ expect(report(origin, { originUrl: origin, defaultBranchGuessed: true })).to.contain(
339
+ "the remote didn't name its default branch"
340
+ );
341
+ expect(report(undefined, { defaultBranchGuessed: true })).to.contain('no remote names the default branch');
342
+ expect(report(origin)).to.not.contain('default branch, so');
343
+ });
344
+
345
+ it('suggests no server for an origin that is a path on this machine', () => {
346
+ [
347
+ '/tmp/e2e/remote.git',
348
+ '../remote.git',
349
+ 'remote.git',
350
+ 'repos/remote.git',
351
+ 'file:///srv/remote.git',
352
+ 'C:\\repos\\remote.git',
353
+ ].forEach((origin) => {
354
+ expect(isLocalPathRemote(origin), origin).to.be.true;
355
+ const out = report(origin, { originUrl: origin });
356
+ expect(out, origin).to.contain(`origin ${origin}`);
357
+ expect(out, origin).to.not.contain('GITHUB_SERVER_URL');
358
+ expect(out, origin).to.not.contain('<your-server>');
359
+ expect(out, origin).to.contain('git push -u origin HEAD');
360
+ });
361
+ expect(isLocalPathRemote('git@ghe.example:acme/store.git')).to.be.false;
362
+ expect(isLocalPathRemote('https://ghe.example/acme/store.git')).to.be.false;
363
+ expect(isLocalPathRemote('ssh://git@ghe.example:2222/acme/store.git')).to.be.false;
364
+ expect(isLocalPathRemote('ghe.example:acme/store.git')).to.be.false;
365
+ });
366
+
367
+ it('prints control characters in an origin as inert text, and suggests no server for it', () => {
368
+ expect(escapeControlChars('a\nb\u001b[2Jc')).to.equal('a\\x0ab\\x1b[2Jc');
369
+ const origin = 'https://ghe.example/acme/store.git\n\u001b[32m✔ all set';
370
+ const out = renderInitReport({
371
+ scopeId: 'acme.shop',
372
+ originUrl: origin,
373
+ defaultBranch: 'main',
374
+ files,
375
+ configAdded: true,
376
+ });
377
+ expect(out).to.not.contain('\u001b[32m');
378
+ expect(out).to.not.contain('store.git\n');
379
+ expect(out).to.contain('origin https://ghe.example/acme/store.git\\x0a\\x1b[32m');
380
+ expect(out).to.not.contain('GITHUB_SERVER_URL');
381
+ });
382
+
383
+ it('never prints the credentials an origin URL carries', () => {
384
+ expect(redactRemoteUrl('https://user:ghp_secret@git.corp.example/acme/store.git')).to.equal(
385
+ 'https://git.corp.example/acme/store.git'
386
+ );
387
+ expect(redactRemoteUrl('https://ghp_secret@git.corp.example/acme/store.git')).to.equal(
388
+ 'https://git.corp.example/acme/store.git'
389
+ );
390
+ expect(redactRemoteUrl('ssh://git:secret@git.corp.example/acme/store.git')).to.equal(
391
+ 'ssh://git@git.corp.example/acme/store.git'
392
+ );
393
+ expect(redactRemoteUrl('ssh://git@git.corp.example/acme/store.git')).to.equal(
394
+ 'ssh://git@git.corp.example/acme/store.git'
395
+ );
396
+ expect(redactRemoteUrl('git@git.corp.example:acme/store.git')).to.equal('git@git.corp.example:acme/store.git');
397
+ const origin = 'https://user:ghp_secret@git.corp.example:8443/acme/store.git';
398
+ const out = report(origin, { originUrl: origin });
399
+ expect(out).to.not.contain('ghp_secret');
400
+ expect(out).to.contain('origin https://git.corp.example:8443/acme/store.git');
401
+ expect(out).to.contain('GITHUB_SERVER_URL=https://git.corp.example:8443 bit ci sync --init');
402
+ });
403
+
404
+ it('suggests a GITHUB_SERVER_URL that keeps an https port, and drops an ssh one', () => {
405
+ expect(suggestedServerUrl('https://ghe.example:8443/acme/store.git')).to.equal('https://ghe.example:8443');
406
+ expect(suggestedServerUrl('git@ghe.example:acme/store.git')).to.equal('https://ghe.example');
407
+ expect(suggestedServerUrl('ssh://git@ghe.example:2222/acme/store.git')).to.equal('https://ghe.example');
408
+ const origin = 'https://ghe.example:8443/acme/store.git';
409
+ expect(report(origin, { originUrl: origin })).to.contain(
410
+ 'GITHUB_SERVER_URL=https://ghe.example:8443 bit ci sync --init'
411
+ );
412
+ });
413
+
414
+ describe('on GitHub Enterprise Server', () => {
147
415
  const saved: Record<string, string | undefined> = {};
148
416
  beforeEach(() => {
149
417
  saved.api = process.env.GITHUB_API_URL;
@@ -157,43 +425,37 @@ describe('init-scaffold', () => {
157
425
  else process.env.GITHUB_SERVER_URL = saved.server;
158
426
  });
159
427
 
160
- it('names api.github.com for a github.com origin', () => {
428
+ it('uses the dispatch webhook on the server API, and gh commands that name the host', () => {
161
429
  process.env.GITHUB_API_URL = 'https://ghe.corp.example/api/v3';
162
- const checklist = renderInitChecklist(deriveOwnerRepo('git@github.com:acme/shop.git'));
163
- expect(checklist).to.contain('https://api.github.com/repos/acme/shop/dispatches');
164
- expect(checklist).to.not.contain('ghe.corp.example');
430
+ const out = report('git@ghe.corp.example:acme/store.git');
431
+ expect(out).to.contain('https://ghe.corp.example/api/v3/repos/acme/store/dispatches');
432
+ expect(out).to.contain('"event_type":"bit-export"');
433
+ expect(out).to.contain('gh secret set BIT_CONFIG_ACCESS_TOKEN -R ghe.corp.example/acme/store');
434
+ expect(out).to.contain('gh api --hostname ghe.corp.example -X PUT repos/acme/store/');
435
+ expect(out).to.not.contain('github.com/apps/bit-git-sync');
165
436
  });
166
437
 
167
- it('derives <server>/api/v3 for an enterprise origin when GITHUB_API_URL is unset', () => {
438
+ it('derives <server>/api/v3 when GITHUB_API_URL is unset', () => {
168
439
  delete process.env.GITHUB_API_URL;
169
- expect(renderInitChecklist(deriveOwnerRepo('https://ghe.corp.example/acme/store.git'))).to.contain(
440
+ expect(report('https://ghe.corp.example/acme/store.git')).to.contain(
170
441
  'https://ghe.corp.example/api/v3/repos/acme/store/dispatches'
171
442
  );
172
443
  });
173
444
 
174
- it('names no API for an enterprise origin whose GITHUB_API_URL is on another host', () => {
445
+ it('names no API when GITHUB_API_URL is on another host', () => {
175
446
  process.env.GITHUB_API_URL = 'https://api.github.com';
176
- const checklist = renderInitChecklist(deriveOwnerRepo('https://ghe.corp.example/acme/store.git'));
177
- expect(checklist).to.contain('<GitHub API URL>/repos/acme/store/dispatches');
178
- expect(checklist).to.contain('GITHUB_API_URL "https://api.github.com"');
179
- expect(checklist).to.not.contain('https://api.github.com/repos');
447
+ const out = report('https://ghe.corp.example/acme/store.git');
448
+ expect(out).to.contain('<GitHub API URL>/repos/acme/store/dispatches');
449
+ expect(out).to.contain('GITHUB_API_URL "https://api.github.com"');
450
+ expect(out).to.not.contain('https://api.github.com/repos');
180
451
  });
181
- });
182
452
 
183
- it('always includes the secrets, permissions, webhook and fetch-depth checklist items', () => {
184
- const checklist = renderInitChecklist(undefined);
185
- [
186
- 'BIT_CONFIG_ACCESS_TOKEN',
187
- 'BIT_SYNC_GH_TOKEN',
188
- 'contents: write',
189
- 'Components > Export',
190
- 'Authorization: Bearer <PAT>',
191
- 'Accept: application/vnd.github+json',
192
- '"event_type":"bit-export"',
193
- '{{laneId}}',
194
- 'drops its custom headers',
195
- 'fetch-depth: 0',
196
- ].forEach((item) => expect(checklist, item).to.contain(item));
453
+ it('keeps the App path for a github.com origin', () => {
454
+ process.env.GITHUB_API_URL = 'https://ghe.corp.example/api/v3';
455
+ const out = report('git@github.com:acme/shop.git');
456
+ expect(out).to.contain('https://github.com/apps/bit-git-sync');
457
+ expect(out).to.not.contain('ghe.corp.example');
458
+ });
197
459
  });
198
460
  });
199
461
  });