@biffo/cli 0.198.2 → 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 +195 -46
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1070,6 +1070,15 @@ var GitAdapter = class {
1070
1070
  async createBranch(cwd, branch) {
1071
1071
  await execa2("git", ["switch", "-c", branch], { cwd });
1072
1072
  }
1073
+ /**
1074
+ * Switch to an existing branch. Fails if it does not exist, and — deliberately
1075
+ * — if the switch would discard uncommitted work: this is the undo half of
1076
+ * `createBranch` (#984), so it must never be able to destroy the tree it is
1077
+ * putting back.
1078
+ */
1079
+ async switchBranch(cwd, branch) {
1080
+ await execa2("git", ["switch", branch], { cwd });
1081
+ }
1073
1082
  /**
1074
1083
  * Push the current HEAD to `branch` on the remote. When `token` is given and
1075
1084
  * the remote is HTTPS, it's embedded in the push URL for auth (SSH/file
@@ -1158,6 +1167,53 @@ function injectToken(repoUrl, token) {
1158
1167
  // src/adapters/source-control/github/index.ts
1159
1168
  import { execSync } from "child_process";
1160
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
1161
1217
  var DEFAULT_STATUS_CHECKS = [
1162
1218
  "JS (lint, types, test, audit)",
1163
1219
  "Python (lint, types, test, security)",
@@ -1557,49 +1613,96 @@ var GitHubAdapter = class {
1557
1613
  await this.octokit.repos.update({ owner: org, repo, default_branch: branch });
1558
1614
  log.info(`Default branch set to ${branch}`);
1559
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
+ */
1560
1632
  async configureBranchProtection(config, protectionIntervalMs = 3e3, statusChecks = DEFAULT_STATUS_CHECKS) {
1561
1633
  const { org, repo } = config.source_control.config;
1562
1634
  const branches = ["dev", "staging", "main"];
1563
- for (const branch of branches) {
1564
- log.info(`Waiting for ${branch} branch to be ready...`);
1565
- await this.waitForBranch(org, repo, branch);
1566
- log.info(`Configuring branch protection on ${branch}...`);
1567
- const params = {
1568
- owner: org,
1569
- repo,
1570
- branch,
1571
- required_status_checks: { strict: true, contexts: statusChecks },
1572
- enforce_admins: false,
1573
- required_pull_request_reviews: {
1574
- required_approving_review_count: 0,
1575
- dismiss_stale_reviews: false
1576
- },
1577
- restrictions: null,
1578
- required_linear_history: true,
1579
- allow_force_pushes: false,
1580
- allow_deletions: false
1581
- };
1582
- const deadline = Date.now() + 3e4;
1583
- while (true) {
1584
- try {
1585
- await this.octokit.repos.updateBranchProtection(params);
1586
- break;
1587
- } catch (err) {
1588
- const status = err.status;
1589
- if (status === 403) {
1590
- log.warn(`Branch protection unavailable for ${org}/${repo}: ${err.message}`);
1591
- log.warn(
1592
- " 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."
1593
- );
1594
- 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));
1595
1684
  }
1596
- if (status !== 404 || Date.now() >= deadline) throw err;
1597
- log.info("Branch protection endpoint not yet ready, retrying...");
1598
- await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
1599
1685
  }
1600
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;
1601
1697
  }
1602
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
+ });
1603
1706
  }
1604
1707
  /**
1605
1708
  * Protect a single branch with caller-supplied required checks (#803).
@@ -2937,8 +3040,44 @@ async function applyAndOpenPr(options, deps, plan, migrations, fromVersion, toVe
2937
3040
  throw new Error(`${options.cwd} is not a git repository.`);
2938
3041
  }
2939
3042
  const branch = upgradeBranchName(fromVersion, toVersion);
3043
+ const callerBranch = await git.currentBranch(options.cwd);
2940
3044
  log.step(1, 4, `Creating branch ${branch}`);
2941
3045
  await git.createBranch(options.cwd, branch);
3046
+ try {
3047
+ await buildCommitAndOpenPr(
3048
+ options,
3049
+ deps,
3050
+ plan,
3051
+ migrations,
3052
+ fromVersion,
3053
+ toVersion,
3054
+ breaking,
3055
+ theirsDir,
3056
+ coreVersionCleanup,
3057
+ branch,
3058
+ token
3059
+ );
3060
+ } finally {
3061
+ await restoreCallerBranch(git, options.cwd, callerBranch, branch);
3062
+ }
3063
+ }
3064
+ async function restoreCallerBranch(git, cwd, callerBranch, upgradeBranch) {
3065
+ if (callerBranch === "HEAD" || callerBranch === "") {
3066
+ log.warn(
3067
+ `Left ${cwd} on ${upgradeBranch}: HEAD was detached on entry, so there is no branch to restore (#984).`
3068
+ );
3069
+ return;
3070
+ }
3071
+ try {
3072
+ await git.switchBranch(cwd, callerBranch);
3073
+ } catch {
3074
+ log.warn(
3075
+ `Could not switch ${cwd} back to ${callerBranch} \u2014 it is still on ${upgradeBranch}. Restore it with \`git switch ${callerBranch}\` (#984).`
3076
+ );
3077
+ }
3078
+ }
3079
+ async function buildCommitAndOpenPr(options, deps, plan, migrations, fromVersion, toVersion, breaking, theirsDir, coreVersionCleanup, branch, token) {
3080
+ const { git } = deps;
2942
3081
  const applied = applyUpgradePlan(options.cwd, plan, theirsDir);
2943
3082
  const carried = applyMigrationCarry(options.cwd, migrations);
2944
3083
  writeInstanceCoreVersion(options.cwd, toVersion);
@@ -5643,11 +5782,15 @@ async function runSiblingCreateCommand(name, options) {
5643
5782
  const aws = new AwsAdapter(config);
5644
5783
  const coreAws = new AwsAdapter(coreConfig);
5645
5784
  const git = new GitAdapter();
5646
- await runSiblingCreate(github, aws, coreAws, git, config, session, {
5647
- coreConfig,
5648
- skeletonRoot: options.templateRoot,
5649
- githubToken: token
5650
- });
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
+ }
5651
5794
  const { org, repo } = githubRepo(config);
5652
5795
  const pathPrefix = resolvePathPrefix(config);
5653
5796
  log.success("\nSibling repo created successfully!");
@@ -5781,6 +5924,7 @@ async function runSiblingCreate(github, aws, coreAws, git, config, session, opti
5781
5924
  } else {
5782
5925
  log.step(8, totalSteps, "Already registered with the core project \u2014 skipping");
5783
5926
  }
5927
+ reportBranchProtectionSummary();
5784
5928
  deleteSiblingSession(config.project.name);
5785
5929
  }
5786
5930
  function resolvePathPrefix(config) {
@@ -6197,12 +6341,16 @@ var initCommand = new Command12("init").description("Scaffold a new project from
6197
6341
  githubToken ??= await resolveGithubToken3(options.yes === true || Boolean(options.config));
6198
6342
  const github = new GitHubAdapter(githubToken);
6199
6343
  const aws = new AwsAdapter(config);
6200
- await runInit(github, aws, config, session, {
6201
- git: new GitAdapter(),
6202
- awsFor: (siblingConfig) => new AwsAdapter(siblingConfig),
6203
- skeletonRoot: defaultSiblingTemplateRoot(),
6204
- githubToken
6205
- });
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
+ }
6206
6354
  const { org, repo } = config.source_control.config;
6207
6355
  const appRepo = rootSiblingProjectName(config.project.name);
6208
6356
  log.success("\nProject initialised successfully!");
@@ -6340,6 +6488,7 @@ async function runInit(github, aws, config, session, appSibling) {
6340
6488
  log.step(6, totalSteps, "Application sibling already created \u2014 skipping");
6341
6489
  }
6342
6490
  }
6491
+ reportBranchProtectionSummary();
6343
6492
  deleteSession(config.project.name);
6344
6493
  saveProjectConfig(config);
6345
6494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.198.2",
3
+ "version": "0.198.4",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",