@biffo/cli 0.165.0 → 0.166.1
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/dist/index.js +239 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1506,6 +1506,70 @@ var GitHubAdapter = class {
|
|
|
1506
1506
|
}
|
|
1507
1507
|
log.success("Branch protection configured on dev, staging, and main");
|
|
1508
1508
|
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Protect a single branch with caller-supplied required checks (#803).
|
|
1511
|
+
*
|
|
1512
|
+
* `configureBranchProtection` above exists for **deployable** repos and
|
|
1513
|
+
* protects `dev`, `staging` and `main` with `DEFAULT_STATUS_CHECKS` — the
|
|
1514
|
+
* core workflow's job names. Neither is right for a plugin repo:
|
|
1515
|
+
*
|
|
1516
|
+
* - A plugin repo is **non-deployable** and has `dev` only (AGENTS.md §2).
|
|
1517
|
+
* Reusing the deployable path would demand two branches it should not have,
|
|
1518
|
+
* and `waitForBranch` would hang on them for two minutes each before
|
|
1519
|
+
* failing.
|
|
1520
|
+
* - Its checks are its own CI's job names (`Lint`, `Type Check`, `Test`, …),
|
|
1521
|
+
* not the core workflow's. Requiring a context that is never reported
|
|
1522
|
+
* blocks every PR for ever, on a branch whose CI is entirely green.
|
|
1523
|
+
*
|
|
1524
|
+
* `statusChecks` must be non-empty. Protection that requires nothing is worse
|
|
1525
|
+
* than none: it reads as configured while gating on nothing, so callers that
|
|
1526
|
+
* could not determine the contexts must not silently land here.
|
|
1527
|
+
*/
|
|
1528
|
+
async protectSingleBranch(org, repo, branch, statusChecks, protectionIntervalMs = 3e3) {
|
|
1529
|
+
if (statusChecks.length === 0) {
|
|
1530
|
+
throw new Error(
|
|
1531
|
+
`Refusing to protect ${org}/${repo}@${branch} with no required status checks \u2014 that reads as protected while gating on nothing. Determine the workflow's job contexts first.`
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
log.info(`Waiting for ${branch} branch to be ready...`);
|
|
1535
|
+
await this.waitForBranch(org, repo, branch);
|
|
1536
|
+
log.info(`Configuring branch protection on ${branch}...`);
|
|
1537
|
+
const params = {
|
|
1538
|
+
owner: org,
|
|
1539
|
+
repo,
|
|
1540
|
+
branch,
|
|
1541
|
+
required_status_checks: { strict: true, contexts: statusChecks },
|
|
1542
|
+
enforce_admins: false,
|
|
1543
|
+
required_pull_request_reviews: {
|
|
1544
|
+
required_approving_review_count: 0,
|
|
1545
|
+
dismiss_stale_reviews: false
|
|
1546
|
+
},
|
|
1547
|
+
restrictions: null,
|
|
1548
|
+
required_linear_history: true,
|
|
1549
|
+
allow_force_pushes: false,
|
|
1550
|
+
allow_deletions: false
|
|
1551
|
+
};
|
|
1552
|
+
const deadline = Date.now() + 3e4;
|
|
1553
|
+
while (true) {
|
|
1554
|
+
try {
|
|
1555
|
+
await this.octokit.repos.updateBranchProtection(params);
|
|
1556
|
+
break;
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
const status = err.status;
|
|
1559
|
+
if (status === 403) {
|
|
1560
|
+
log.warn(`Branch protection unavailable for ${org}/${repo}: ${err.message}`);
|
|
1561
|
+
log.warn(" Add it later via GitHub once the plan allows it, or make the repo public.");
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (status !== 404 || Date.now() >= deadline) throw err;
|
|
1565
|
+
log.info("Branch protection endpoint not yet ready, retrying...");
|
|
1566
|
+
await new Promise((resolve18) => setTimeout(resolve18, protectionIntervalMs));
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
log.success(
|
|
1570
|
+
`Branch protection configured on ${branch} (${statusChecks.length} required check(s))`
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1509
1573
|
async createEnvironments(config) {
|
|
1510
1574
|
const { org, repo } = config.source_control.config;
|
|
1511
1575
|
for (const env of config.environments) {
|
|
@@ -1565,6 +1629,34 @@ var GitHubAdapter = class {
|
|
|
1565
1629
|
throw err;
|
|
1566
1630
|
}
|
|
1567
1631
|
}
|
|
1632
|
+
/**
|
|
1633
|
+
* How many self-hosted runners this repo can actually see (#803, biffo-runners#2).
|
|
1634
|
+
*
|
|
1635
|
+
* Setting `RUNNER_LABEL` points a repo's jobs at a self-hosted fleet. It does
|
|
1636
|
+
* not grant the fleet's GitHub App access to that repo — and until someone
|
|
1637
|
+
* does, the repo sees **zero** runners and every job queues for ever with no
|
|
1638
|
+
* error at all. Measured on a freshly created plugin repo: 0 runners, while an
|
|
1639
|
+
* established one in the same fleet saw 3.
|
|
1640
|
+
*
|
|
1641
|
+
* Queuing for ever and failing at the billing wall look completely different
|
|
1642
|
+
* in the UI and are the same outcome under branch protection: nothing can
|
|
1643
|
+
* merge. Callers use this to say so at create time rather than leaving it to
|
|
1644
|
+
* be discovered on the first PR.
|
|
1645
|
+
*
|
|
1646
|
+
* Returns `null` when the count cannot be read (permissions, API error) —
|
|
1647
|
+
* distinct from `0`, which is a real and actionable answer.
|
|
1648
|
+
*/
|
|
1649
|
+
async repoRunnerCount(org, repo) {
|
|
1650
|
+
try {
|
|
1651
|
+
const { data } = await this.octokit.actions.listSelfHostedRunnersForRepo({
|
|
1652
|
+
owner: org,
|
|
1653
|
+
repo
|
|
1654
|
+
});
|
|
1655
|
+
return data.total_count;
|
|
1656
|
+
} catch {
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1568
1660
|
async setRepoVariable(org, repo, name, value) {
|
|
1569
1661
|
log.info(`Setting variable: ${name}`);
|
|
1570
1662
|
try {
|
|
@@ -6370,6 +6462,50 @@ import { fileURLToPath as fileURLToPath5 } from "url";
|
|
|
6370
6462
|
import chalk13 from "chalk";
|
|
6371
6463
|
import { Command as Command13 } from "commander";
|
|
6372
6464
|
|
|
6465
|
+
// src/lib/workflow-check-contexts.ts
|
|
6466
|
+
function unquote(value) {
|
|
6467
|
+
const trimmed = value.trim();
|
|
6468
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length > 1 || trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length > 1) {
|
|
6469
|
+
return trimmed.slice(1, -1);
|
|
6470
|
+
}
|
|
6471
|
+
return trimmed;
|
|
6472
|
+
}
|
|
6473
|
+
function workflowCheckContexts(workflow) {
|
|
6474
|
+
const lines = workflow.split("\n");
|
|
6475
|
+
const jobsIndex = lines.findIndex((line) => /^jobs:\s*(#.*)?$/.test(line));
|
|
6476
|
+
if (jobsIndex === -1) return [];
|
|
6477
|
+
const contexts = [];
|
|
6478
|
+
let currentJobId = null;
|
|
6479
|
+
let currentJobIndent = 0;
|
|
6480
|
+
let currentName = null;
|
|
6481
|
+
const flush = () => {
|
|
6482
|
+
if (currentJobId !== null) contexts.push(currentName ?? currentJobId);
|
|
6483
|
+
currentJobId = null;
|
|
6484
|
+
currentName = null;
|
|
6485
|
+
};
|
|
6486
|
+
for (const line of lines.slice(jobsIndex + 1)) {
|
|
6487
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
6488
|
+
if (!/^\s/.test(line)) break;
|
|
6489
|
+
const jobMatch = /^(\s+)([A-Za-z0-9_-]+):\s*(#.*)?$/.exec(line);
|
|
6490
|
+
if (jobMatch?.[1] !== void 0 && jobMatch[2] !== void 0) {
|
|
6491
|
+
const indent = jobMatch[1].length;
|
|
6492
|
+
if (currentJobId === null || indent === currentJobIndent) {
|
|
6493
|
+
flush();
|
|
6494
|
+
currentJobId = jobMatch[2];
|
|
6495
|
+
currentJobIndent = indent;
|
|
6496
|
+
continue;
|
|
6497
|
+
}
|
|
6498
|
+
}
|
|
6499
|
+
if (currentJobId === null) continue;
|
|
6500
|
+
const nameMatch = /^(\s+)name:\s*(.+?)\s*$/.exec(line);
|
|
6501
|
+
if (nameMatch?.[1] !== void 0 && nameMatch[2] !== void 0 && nameMatch[1].length === currentJobIndent + 2 && currentName === null) {
|
|
6502
|
+
currentName = unquote(nameMatch[2]);
|
|
6503
|
+
}
|
|
6504
|
+
}
|
|
6505
|
+
flush();
|
|
6506
|
+
return contexts;
|
|
6507
|
+
}
|
|
6508
|
+
|
|
6373
6509
|
// src/lib/plugin-locations.ts
|
|
6374
6510
|
import { existsSync as existsSync21, readdirSync as readdirSync10 } from "fs";
|
|
6375
6511
|
import { join as join21 } from "path";
|
|
@@ -6728,6 +6864,12 @@ var pluginCreateCommand = new Command13("create").description("Scaffold a new pl
|
|
|
6728
6864
|
).option(
|
|
6729
6865
|
"--standalone",
|
|
6730
6866
|
"Scaffold a standalone plugin repo (ADR-0003 section 2) into ./biffo-plugin-<name>/ instead of into this checkout, keeping its own CI/CD workflows"
|
|
6867
|
+
).option(
|
|
6868
|
+
"--org <org>",
|
|
6869
|
+
"With --standalone, also create the GitHub repo under this org (or user), push, and protect dev"
|
|
6870
|
+
).option(
|
|
6871
|
+
"--runner-label <label>",
|
|
6872
|
+
"With --org, the RUNNER_LABEL the new repo\u2019s CI should run on (defaults to mirroring this checkout\u2019s)"
|
|
6731
6873
|
).option(
|
|
6732
6874
|
"--skeleton <path>",
|
|
6733
6875
|
"Path to the plugin skeleton (defaults to _skeletons/plugin-template)"
|
|
@@ -6740,12 +6882,18 @@ var pluginCreateCommand = new Command13("create").description("Scaffold a new pl
|
|
|
6740
6882
|
{
|
|
6741
6883
|
firstParty: options.firstParty ?? false,
|
|
6742
6884
|
standalone: options.standalone ?? false,
|
|
6885
|
+
...options.org ? { org: options.org } : {},
|
|
6886
|
+
...options.runnerLabel ? { runnerLabel: options.runnerLabel } : {},
|
|
6743
6887
|
...options.skeleton ? { skeletonRoot: resolve11(options.skeleton) } : {},
|
|
6744
6888
|
dryRun: options.dryRun ?? false,
|
|
6745
6889
|
commit: options.commit !== false,
|
|
6746
6890
|
cwd
|
|
6747
6891
|
},
|
|
6748
|
-
{
|
|
6892
|
+
{
|
|
6893
|
+
git: new GitAdapter(),
|
|
6894
|
+
makeGitHub: (token) => new GitHubAdapter(token),
|
|
6895
|
+
resolveToken: resolveGithubToken3
|
|
6896
|
+
}
|
|
6749
6897
|
);
|
|
6750
6898
|
} catch (err) {
|
|
6751
6899
|
log.error(err.message);
|
|
@@ -6858,8 +7006,98 @@ async function runStandaloneCreate(names, options, deps) {
|
|
|
6858
7006
|
await deps.git.commit(destDir, `feat: scaffold ${names.slug} plugin`);
|
|
6859
7007
|
log.success(`Initialised a git repo on dev with an initial commit`);
|
|
6860
7008
|
}
|
|
7009
|
+
if (options.org !== void 0 && options.org !== "") {
|
|
7010
|
+
if (!options.commit) {
|
|
7011
|
+
throw new Error(
|
|
7012
|
+
"--org needs a commit to push. Drop --no-commit, or create the repo yourself using the steps printed by a --no-commit run."
|
|
7013
|
+
);
|
|
7014
|
+
}
|
|
7015
|
+
await createAndPushStandaloneRepo(options.org, names, destDir, options, deps);
|
|
7016
|
+
return;
|
|
7017
|
+
}
|
|
6861
7018
|
printStandaloneNextSteps(names, minorOf(manifest.version));
|
|
6862
7019
|
}
|
|
7020
|
+
async function createAndPushStandaloneRepo(org, names, destDir, options, deps) {
|
|
7021
|
+
if (deps.makeGitHub === void 0 || deps.resolveToken === void 0) {
|
|
7022
|
+
throw new Error("No GitHub adapter available \u2014 cannot create a repository.");
|
|
7023
|
+
}
|
|
7024
|
+
const token = await deps.resolveToken();
|
|
7025
|
+
const github = deps.makeGitHub(token);
|
|
7026
|
+
const cloneUrl = await github.createEmptyRepo(org, names.dist, `${names.slug} \u2014 a Biffo plugin`);
|
|
7027
|
+
await propagateRunnerLabel(org, names.dist, options, deps, github);
|
|
7028
|
+
await deps.git.addRemote(destDir, "origin", cloneUrl);
|
|
7029
|
+
await deps.git.push(destDir, "dev", { token });
|
|
7030
|
+
log.success(`Pushed dev to ${org}/${names.dist}`);
|
|
7031
|
+
await github.setDefaultBranch(org, names.dist, "dev");
|
|
7032
|
+
const ciPath = join23(destDir, ".github", "workflows", "ci.yml");
|
|
7033
|
+
const contexts = existsSync23(ciPath) ? workflowCheckContexts(readFileSync17(ciPath, "utf8")) : [];
|
|
7034
|
+
if (contexts.length === 0) {
|
|
7035
|
+
log.warn(
|
|
7036
|
+
`Could not determine required status checks from ${ciPath} \u2014 skipping branch protection. Configure it manually on dev once you know the CI job names.`
|
|
7037
|
+
);
|
|
7038
|
+
} else {
|
|
7039
|
+
await github.protectSingleBranch(org, names.dist, "dev", contexts);
|
|
7040
|
+
}
|
|
7041
|
+
printStandaloneRemoteNextSteps(names, org);
|
|
7042
|
+
}
|
|
7043
|
+
async function propagateRunnerLabel(org, repo, options, deps, github) {
|
|
7044
|
+
try {
|
|
7045
|
+
let label = options.runnerLabel?.trim();
|
|
7046
|
+
if (!label) {
|
|
7047
|
+
const source = await currentRepoSlug(options.cwd, deps);
|
|
7048
|
+
if (source) {
|
|
7049
|
+
label = (await github.getRepoVariable(source.org, source.repo, "RUNNER_LABEL"))?.trim();
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
7052
|
+
if (!label) {
|
|
7053
|
+
log.warn(
|
|
7054
|
+
`No RUNNER_LABEL set on ${org}/${repo} \u2014 its CI will use GitHub-hosted runners. If this account routes CI to a self-hosted fleet, every job will fail before it starts, and the branch protection just configured will block every PR on checks that never report. Fix with: gh variable set RUNNER_LABEL --repo ${org}/${repo}`
|
|
7055
|
+
);
|
|
7056
|
+
return;
|
|
7057
|
+
}
|
|
7058
|
+
await github.setRepoVariable(org, repo, "RUNNER_LABEL", label);
|
|
7059
|
+
log.success(`RUNNER_LABEL=${label} set on ${org}/${repo}`);
|
|
7060
|
+
const runners = await github.repoRunnerCount(org, repo);
|
|
7061
|
+
if (runners === 0) {
|
|
7062
|
+
log.warn(
|
|
7063
|
+
`${org}/${repo} points at the '${label}' runner fleet but can see 0 runners. Jobs will queue indefinitely with no error until the fleet's GitHub App is granted access to this repo (biffo-runners#2) \u2014 and the branch protection just applied will block every PR until then.`
|
|
7064
|
+
);
|
|
7065
|
+
}
|
|
7066
|
+
} catch (err) {
|
|
7067
|
+
log.warn(
|
|
7068
|
+
`Could not set RUNNER_LABEL on ${org}/${repo}: ${err.message}. Set it manually if this account uses self-hosted runners.`
|
|
7069
|
+
);
|
|
7070
|
+
}
|
|
7071
|
+
}
|
|
7072
|
+
async function currentRepoSlug(cwd, deps) {
|
|
7073
|
+
try {
|
|
7074
|
+
const url = await deps.git.getRemoteUrl(cwd, "origin");
|
|
7075
|
+
const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(url.trim());
|
|
7076
|
+
if (match?.[1] === void 0 || match[2] === void 0) return null;
|
|
7077
|
+
return { org: match[1], repo: match[2] };
|
|
7078
|
+
} catch {
|
|
7079
|
+
return null;
|
|
7080
|
+
}
|
|
7081
|
+
}
|
|
7082
|
+
function printStandaloneRemoteNextSteps(names, org) {
|
|
7083
|
+
console.log(chalk13.bold("\n Standalone plugin repo created!\n"));
|
|
7084
|
+
console.log(` https://github.com/${org}/${names.dist} (dev, protected)
|
|
7085
|
+
`);
|
|
7086
|
+
console.log(" Next:");
|
|
7087
|
+
console.log(chalk13.dim(` 1. cd ${names.dist} && edit biffo.plugin.json`));
|
|
7088
|
+
console.log(
|
|
7089
|
+
chalk13.dim(" 2. Add it to the registry so it appears in the plugin store \u2014 either set")
|
|
7090
|
+
);
|
|
7091
|
+
console.log(
|
|
7092
|
+
chalk13.dim(
|
|
7093
|
+
" REGISTRY_PUBLISH_TOKEN on the repo, or add it to the registry\u2019s sources.json"
|
|
7094
|
+
)
|
|
7095
|
+
);
|
|
7096
|
+
console.log(
|
|
7097
|
+
chalk13.dim(` 3. Consumers install it with: biffo plugin install ${names.slug}@<minor>
|
|
7098
|
+
`)
|
|
7099
|
+
);
|
|
7100
|
+
}
|
|
6863
7101
|
function minorOf(version) {
|
|
6864
7102
|
const match = /^(\d+)\.(\d+)/.exec(version);
|
|
6865
7103
|
return match ? `${match[1]}.${match[2]}` : version;
|