@biffo/cli 0.198.3 → 0.198.4

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 (2) hide show
  1. package/dist/index.js +150 -46
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1167,6 +1167,53 @@ function injectToken(repoUrl, token) {
1167
1167
  // src/adapters/source-control/github/index.ts
1168
1168
  import { execSync } from "child_process";
1169
1169
  import { Octokit } from "@octokit/rest";
1170
+
1171
+ // src/lib/branch-protection-outcome.ts
1172
+ var pending = [];
1173
+ function recordBranchProtectionOutcome(outcome) {
1174
+ pending.push(outcome);
1175
+ return outcome;
1176
+ }
1177
+ function isUnprotected(outcome) {
1178
+ return outcome.status !== "applied" || outcome.unprotectedBranches.length > 0;
1179
+ }
1180
+ function formatBranchProtectionSummary(outcomes) {
1181
+ if (outcomes.length === 0) return [];
1182
+ const unprotected = outcomes.filter(isUnprotected);
1183
+ if (unprotected.length === 0) {
1184
+ return [
1185
+ `Branch protection applied to ${outcomes.map((o) => `${o.org}/${o.repo}`).sort().join(", ")}`
1186
+ ];
1187
+ }
1188
+ const lines = [
1189
+ `Branch protection was NOT fully applied \u2014 ${unprotected.length} of ${outcomes.length} repositor${outcomes.length === 1 ? "y" : "ies"} created by this run ${unprotected.length === 1 ? "is" : "are"} unprotected:`
1190
+ ];
1191
+ for (const outcome of unprotected) {
1192
+ const left = outcome.unprotectedBranches.join(", ") || "unknown";
1193
+ const why = outcome.status === "skipped-403" ? "GitHub returned 403 \u2014 the org's plan does not allow branch protection on this repo" : outcome.status === "failed" ? "branch protection failed" : "branch protection incomplete";
1194
+ lines.push(` ${outcome.org}/${outcome.repo} \u2014 unprotected: ${left} (${why})`);
1195
+ if (outcome.reason) lines.push(` ${outcome.reason}`);
1196
+ }
1197
+ lines.push(
1198
+ " Direct pushes, force-pushes and merges with red or missing checks are all allowed on those branches right now.",
1199
+ " Fix it with: biffo check branch-protection --fix (after upgrading the plan, or making the repo public)"
1200
+ );
1201
+ return lines;
1202
+ }
1203
+ function reportBranchProtectionSummary() {
1204
+ const outcomes = pending.splice(0, pending.length);
1205
+ const lines = formatBranchProtectionSummary(outcomes);
1206
+ if (lines.length === 0) return outcomes;
1207
+ if (outcomes.some(isUnprotected)) {
1208
+ log.error(lines[0]);
1209
+ for (const line of lines.slice(1)) log.error(line);
1210
+ } else {
1211
+ log.success(lines[0]);
1212
+ }
1213
+ return outcomes;
1214
+ }
1215
+
1216
+ // src/adapters/source-control/github/index.ts
1170
1217
  var DEFAULT_STATUS_CHECKS = [
1171
1218
  "JS (lint, types, test, audit)",
1172
1219
  "Python (lint, types, test, security)",
@@ -1566,49 +1613,96 @@ var GitHubAdapter = class {
1566
1613
  await this.octokit.repos.update({ owner: org, repo, default_branch: branch });
1567
1614
  log.info(`Default branch set to ${branch}`);
1568
1615
  }
1616
+ /**
1617
+ * Protect `dev`, `staging` and `main`, and **say what actually happened**.
1618
+ *
1619
+ * This used to return `Promise<void>`, which made the 403 path (GitHub
1620
+ * refusing branch protection on a private org repo whose plan does not
1621
+ * include it) indistinguishable from success at every call site: the method
1622
+ * logged two warnings and returned, and the scaffold went on to report a
1623
+ * repo created. Nothing durable recorded that protection had been skipped,
1624
+ * so the only trace was a log line in the middle of a long provisioning
1625
+ * transcript. Three repos — including a live core platform — ran completely
1626
+ * unprotected for three weeks on the strength of that (#715, #737 item 2).
1627
+ *
1628
+ * The returned outcome is also pushed onto the run-scoped collector in
1629
+ * `lib/branch-protection-outcome.ts`, so callers that do nothing with the
1630
+ * return value still get named in the end-of-run summary.
1631
+ */
1569
1632
  async configureBranchProtection(config, protectionIntervalMs = 3e3, statusChecks = DEFAULT_STATUS_CHECKS) {
1570
1633
  const { org, repo } = config.source_control.config;
1571
1634
  const branches = ["dev", "staging", "main"];
1572
- for (const branch of branches) {
1573
- log.info(`Waiting for ${branch} branch to be ready...`);
1574
- await this.waitForBranch(org, repo, branch);
1575
- log.info(`Configuring branch protection on ${branch}...`);
1576
- const params = {
1577
- owner: org,
1578
- repo,
1579
- branch,
1580
- required_status_checks: { strict: true, contexts: statusChecks },
1581
- enforce_admins: false,
1582
- required_pull_request_reviews: {
1583
- required_approving_review_count: 0,
1584
- dismiss_stale_reviews: false
1585
- },
1586
- restrictions: null,
1587
- required_linear_history: true,
1588
- allow_force_pushes: false,
1589
- allow_deletions: false
1590
- };
1591
- const deadline = Date.now() + 3e4;
1592
- while (true) {
1593
- try {
1594
- await this.octokit.repos.updateBranchProtection(params);
1595
- break;
1596
- } catch (err) {
1597
- const status = err.status;
1598
- if (status === 403) {
1599
- log.warn(`Branch protection unavailable for ${org}/${repo}: ${err.message}`);
1600
- log.warn(
1601
- " This usually means the organization is on a plan that only supports branch protection on public repos (GitHub Team/Enterprise is required for private org repos). Skipping branch protection \u2014 add it later via GitHub once the plan allows it, or make the repo public."
1602
- );
1603
- return;
1635
+ const applied = [];
1636
+ const remaining = () => branches.filter((b) => !applied.includes(b));
1637
+ try {
1638
+ for (const branch of branches) {
1639
+ log.info(`Waiting for ${branch} branch to be ready...`);
1640
+ await this.waitForBranch(org, repo, branch);
1641
+ log.info(`Configuring branch protection on ${branch}...`);
1642
+ const params = {
1643
+ owner: org,
1644
+ repo,
1645
+ branch,
1646
+ required_status_checks: { strict: true, contexts: statusChecks },
1647
+ enforce_admins: false,
1648
+ required_pull_request_reviews: {
1649
+ required_approving_review_count: 0,
1650
+ dismiss_stale_reviews: false
1651
+ },
1652
+ restrictions: null,
1653
+ required_linear_history: true,
1654
+ allow_force_pushes: false,
1655
+ allow_deletions: false
1656
+ };
1657
+ const deadline = Date.now() + 3e4;
1658
+ while (true) {
1659
+ try {
1660
+ await this.octokit.repos.updateBranchProtection(params);
1661
+ applied.push(branch);
1662
+ break;
1663
+ } catch (err) {
1664
+ const status = err.status;
1665
+ if (status === 403) {
1666
+ log.warn(
1667
+ `Branch protection unavailable for ${org}/${repo}: ${err.message}`
1668
+ );
1669
+ log.warn(
1670
+ " This usually means the organization is on a plan that only supports branch protection on public repos (GitHub Team/Enterprise is required for private org repos). Skipping branch protection \u2014 add it later via GitHub once the plan allows it, or make the repo public."
1671
+ );
1672
+ return recordBranchProtectionOutcome({
1673
+ status: "skipped-403",
1674
+ org,
1675
+ repo,
1676
+ protectedBranches: applied,
1677
+ unprotectedBranches: remaining(),
1678
+ reason: err.message
1679
+ });
1680
+ }
1681
+ if (status !== 404 || Date.now() >= deadline) throw err;
1682
+ log.info("Branch protection endpoint not yet ready, retrying...");
1683
+ await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
1604
1684
  }
1605
- if (status !== 404 || Date.now() >= deadline) throw err;
1606
- log.info("Branch protection endpoint not yet ready, retrying...");
1607
- await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
1608
1685
  }
1609
1686
  }
1687
+ } catch (err) {
1688
+ recordBranchProtectionOutcome({
1689
+ status: "failed",
1690
+ org,
1691
+ repo,
1692
+ protectedBranches: applied,
1693
+ unprotectedBranches: remaining(),
1694
+ reason: err.message
1695
+ });
1696
+ throw err;
1610
1697
  }
1611
1698
  log.success("Branch protection configured on dev, staging, and main");
1699
+ return recordBranchProtectionOutcome({
1700
+ status: "applied",
1701
+ org,
1702
+ repo,
1703
+ protectedBranches: applied,
1704
+ unprotectedBranches: []
1705
+ });
1612
1706
  }
1613
1707
  /**
1614
1708
  * Protect a single branch with caller-supplied required checks (#803).
@@ -5688,11 +5782,15 @@ async function runSiblingCreateCommand(name, options) {
5688
5782
  const aws = new AwsAdapter(config);
5689
5783
  const coreAws = new AwsAdapter(coreConfig);
5690
5784
  const git = new GitAdapter();
5691
- await runSiblingCreate(github, aws, coreAws, git, config, session, {
5692
- coreConfig,
5693
- skeletonRoot: options.templateRoot,
5694
- githubToken: token
5695
- });
5785
+ try {
5786
+ await runSiblingCreate(github, aws, coreAws, git, config, session, {
5787
+ coreConfig,
5788
+ skeletonRoot: options.templateRoot,
5789
+ githubToken: token
5790
+ });
5791
+ } finally {
5792
+ reportBranchProtectionSummary();
5793
+ }
5696
5794
  const { org, repo } = githubRepo(config);
5697
5795
  const pathPrefix = resolvePathPrefix(config);
5698
5796
  log.success("\nSibling repo created successfully!");
@@ -5826,6 +5924,7 @@ async function runSiblingCreate(github, aws, coreAws, git, config, session, opti
5826
5924
  } else {
5827
5925
  log.step(8, totalSteps, "Already registered with the core project \u2014 skipping");
5828
5926
  }
5927
+ reportBranchProtectionSummary();
5829
5928
  deleteSiblingSession(config.project.name);
5830
5929
  }
5831
5930
  function resolvePathPrefix(config) {
@@ -6242,12 +6341,16 @@ var initCommand = new Command12("init").description("Scaffold a new project from
6242
6341
  githubToken ??= await resolveGithubToken3(options.yes === true || Boolean(options.config));
6243
6342
  const github = new GitHubAdapter(githubToken);
6244
6343
  const aws = new AwsAdapter(config);
6245
- await runInit(github, aws, config, session, {
6246
- git: new GitAdapter(),
6247
- awsFor: (siblingConfig) => new AwsAdapter(siblingConfig),
6248
- skeletonRoot: defaultSiblingTemplateRoot(),
6249
- githubToken
6250
- });
6344
+ try {
6345
+ await runInit(github, aws, config, session, {
6346
+ git: new GitAdapter(),
6347
+ awsFor: (siblingConfig) => new AwsAdapter(siblingConfig),
6348
+ skeletonRoot: defaultSiblingTemplateRoot(),
6349
+ githubToken
6350
+ });
6351
+ } finally {
6352
+ reportBranchProtectionSummary();
6353
+ }
6251
6354
  const { org, repo } = config.source_control.config;
6252
6355
  const appRepo = rootSiblingProjectName(config.project.name);
6253
6356
  log.success("\nProject initialised successfully!");
@@ -6385,6 +6488,7 @@ async function runInit(github, aws, config, session, appSibling) {
6385
6488
  log.step(6, totalSteps, "Application sibling already created \u2014 skipping");
6386
6489
  }
6387
6490
  }
6491
+ reportBranchProtectionSummary();
6388
6492
  deleteSession(config.project.name);
6389
6493
  saveProjectConfig(config);
6390
6494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.198.3",
3
+ "version": "0.198.4",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",