@biffo/cli 0.146.3 → 0.148.0
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/_skeletons/plugin-template/AGENTS.md +34 -2
- package/dist/index.js +115 -4
- package/package.json +1 -1
|
@@ -66,7 +66,39 @@ repo; only its product differs.
|
|
|
66
66
|
before merge: `git log origin/<branch> -1`. A green PR page is not proof your
|
|
67
67
|
latest local commit reached it.
|
|
68
68
|
|
|
69
|
-
## 7.
|
|
69
|
+
## 7. Creating this repo correctly (read once, at birth)
|
|
70
|
+
|
|
71
|
+
**No `biffo` command creates a standalone plugin repo.** `biffo plugin create`
|
|
72
|
+
scaffolds a plugin _into an existing checkout_; a repo like this one is made by
|
|
73
|
+
hand. So the governance that `biffo init` and `biffo sibling create` apply
|
|
74
|
+
automatically is **not applied here**, and has to be set deliberately.
|
|
75
|
+
|
|
76
|
+
That gap is not theoretical: both existing plugin repos ran with **no branch
|
|
77
|
+
protection at all**, and `biffo-plugin-ideation#54` was merged with both CI jobs
|
|
78
|
+
still in progress because nothing stopped it (biffo-template#714).
|
|
79
|
+
|
|
80
|
+
After creating the repo and pushing this skeleton:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 1. Auto-merge must be ON, or `gh pr merge --auto` merges IMMEDIATELY
|
|
84
|
+
# rather than queuing — see the warning below.
|
|
85
|
+
gh api -X PATCH repos/<org>/<repo> -f allow_auto_merge=true -f delete_branch_on_merge=true
|
|
86
|
+
|
|
87
|
+
# 2. Protect dev, deriving the required checks from what CI actually reports.
|
|
88
|
+
# Run it once CI has gone green at least twice, so there is something to derive from.
|
|
89
|
+
biffo check branch-protection --repo <org>/<repo> --fix
|
|
90
|
+
|
|
91
|
+
# 3. Confirm it took.
|
|
92
|
+
biffo check branch-protection --repo <org>/<repo>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> **`--auto` is not a safety net on an unconfigured repo.** With
|
|
96
|
+
> `allow_auto_merge` disabled, `gh pr merge --squash --auto` does not queue — it
|
|
97
|
+
> merges _now_ if the PR is mergeable at that instant. On an unprotected branch
|
|
98
|
+
> that means merging with checks still running. Steps 1 and 2 together are what
|
|
99
|
+
> make the documented flow behave as documented; either alone is not enough.
|
|
100
|
+
|
|
101
|
+
## 8. CI runners — two steps this repo cannot do for itself
|
|
70
102
|
|
|
71
103
|
The workflows use `runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}`, so they
|
|
72
104
|
work anywhere by default and route to a self-hosted fleet when one exists. Two
|
|
@@ -83,7 +115,7 @@ fleet:
|
|
|
83
115
|
If a job is queued and nothing is happening, check the grant before anything
|
|
84
116
|
else. It is the failure that looks exactly like patience.
|
|
85
117
|
|
|
86
|
-
##
|
|
118
|
+
## 9. Security
|
|
87
119
|
|
|
88
120
|
- **Never commit secrets** (keys, tokens, credentials, `.env` values).
|
|
89
121
|
- **Never silently disable a security gate.** If one must be loosened, do it in
|
package/dist/index.js
CHANGED
|
@@ -638,6 +638,10 @@ var DEFAULT_STATUS_CHECKS = [
|
|
|
638
638
|
"Secret Scan",
|
|
639
639
|
"Terraform Validate & Security"
|
|
640
640
|
];
|
|
641
|
+
var REPO_MERGE_SETTINGS = {
|
|
642
|
+
allow_auto_merge: true,
|
|
643
|
+
delete_branch_on_merge: true
|
|
644
|
+
};
|
|
641
645
|
var GitHubAdapter = class {
|
|
642
646
|
octokit;
|
|
643
647
|
templateOwner;
|
|
@@ -775,6 +779,7 @@ var GitHubAdapter = class {
|
|
|
775
779
|
org,
|
|
776
780
|
name: repo,
|
|
777
781
|
private: true,
|
|
782
|
+
...REPO_MERGE_SETTINGS,
|
|
778
783
|
...description !== void 0 ? { description } : {}
|
|
779
784
|
});
|
|
780
785
|
log.success(`Repository created: ${data2.html_url}`);
|
|
@@ -785,6 +790,7 @@ var GitHubAdapter = class {
|
|
|
785
790
|
const { data } = await this.octokit.repos.createForAuthenticatedUser({
|
|
786
791
|
name: repo,
|
|
787
792
|
private: true,
|
|
793
|
+
...REPO_MERGE_SETTINGS,
|
|
788
794
|
...description !== void 0 ? { description } : {}
|
|
789
795
|
});
|
|
790
796
|
log.success(`Repository created: ${data.html_url}`);
|
|
@@ -7736,6 +7742,72 @@ import { Command as Command23 } from "commander";
|
|
|
7736
7742
|
import { Octokit as Octokit2 } from "@octokit/rest";
|
|
7737
7743
|
import { execa as execa5 } from "execa";
|
|
7738
7744
|
|
|
7745
|
+
// src/lib/branch-protection-apply.ts
|
|
7746
|
+
var CONTEXT_CONSISTENCY_THRESHOLD = 2 / 3;
|
|
7747
|
+
var RECENT_COMMIT_WINDOW = 6;
|
|
7748
|
+
function deriveRequiredContexts(observed) {
|
|
7749
|
+
const shasNewestFirst = [];
|
|
7750
|
+
for (const { headSha } of observed) {
|
|
7751
|
+
if (!shasNewestFirst.includes(headSha)) shasNewestFirst.push(headSha);
|
|
7752
|
+
}
|
|
7753
|
+
const window = shasNewestFirst.slice(0, RECENT_COMMIT_WINDOW);
|
|
7754
|
+
if (window.length === 0) return [];
|
|
7755
|
+
const seenOn = /* @__PURE__ */ new Map();
|
|
7756
|
+
for (const { name, headSha } of observed) {
|
|
7757
|
+
if (!window.includes(headSha)) continue;
|
|
7758
|
+
const set = seenOn.get(name) ?? /* @__PURE__ */ new Set();
|
|
7759
|
+
set.add(headSha);
|
|
7760
|
+
seenOn.set(name, set);
|
|
7761
|
+
}
|
|
7762
|
+
const required = Math.ceil(window.length * CONTEXT_CONSISTENCY_THRESHOLD);
|
|
7763
|
+
return [...seenOn.entries()].filter(([, shas]) => shas.size >= required).map(([name]) => name).sort();
|
|
7764
|
+
}
|
|
7765
|
+
function protectionParamsFor(contexts) {
|
|
7766
|
+
return {
|
|
7767
|
+
required_status_checks: { strict: true, contexts: [...contexts].sort() },
|
|
7768
|
+
enforce_admins: false,
|
|
7769
|
+
required_pull_request_reviews: {
|
|
7770
|
+
required_approving_review_count: 0,
|
|
7771
|
+
dismiss_stale_reviews: false
|
|
7772
|
+
},
|
|
7773
|
+
restrictions: null,
|
|
7774
|
+
required_linear_history: true,
|
|
7775
|
+
allow_force_pushes: false,
|
|
7776
|
+
allow_deletions: false
|
|
7777
|
+
};
|
|
7778
|
+
}
|
|
7779
|
+
function planProtection(branch, exists, observed) {
|
|
7780
|
+
if (!exists) {
|
|
7781
|
+
return {
|
|
7782
|
+
branch,
|
|
7783
|
+
action: "skip",
|
|
7784
|
+
reason: "branch does not exist",
|
|
7785
|
+
contexts: []
|
|
7786
|
+
};
|
|
7787
|
+
}
|
|
7788
|
+
const contexts = deriveRequiredContexts(observed);
|
|
7789
|
+
if (contexts.length === 0) {
|
|
7790
|
+
return {
|
|
7791
|
+
branch,
|
|
7792
|
+
action: "skip",
|
|
7793
|
+
reason: "no check has reported consistently enough to require \u2014 applying protection with an empty context list would make the branch look protected while admitting any PR",
|
|
7794
|
+
contexts: []
|
|
7795
|
+
};
|
|
7796
|
+
}
|
|
7797
|
+
return {
|
|
7798
|
+
branch,
|
|
7799
|
+
action: "apply",
|
|
7800
|
+
reason: `requiring ${contexts.length} context(s) observed on recent commits`,
|
|
7801
|
+
contexts
|
|
7802
|
+
};
|
|
7803
|
+
}
|
|
7804
|
+
function formatPlans(plans) {
|
|
7805
|
+
return plans.map(
|
|
7806
|
+
(p) => p.action === "apply" ? ` ${p.branch}: apply \u2014 ${p.reason}
|
|
7807
|
+
${p.contexts.map((c) => ` \u2022 ${c}`).join("\n")}` : ` ${p.branch}: skip \u2014 ${p.reason}`
|
|
7808
|
+
).join("\n");
|
|
7809
|
+
}
|
|
7810
|
+
|
|
7739
7811
|
// src/lib/branch-protection-audit.ts
|
|
7740
7812
|
function auditBranch(branch, protection) {
|
|
7741
7813
|
if (protection === null) {
|
|
@@ -7805,7 +7877,16 @@ async function resolveRepo(explicit) {
|
|
|
7805
7877
|
}
|
|
7806
7878
|
return { owner: m[1], repo: m[2] };
|
|
7807
7879
|
}
|
|
7808
|
-
async function
|
|
7880
|
+
async function observedChecks(octokit, owner, repo, branch) {
|
|
7881
|
+
const { data } = await octokit.actions.listWorkflowRunsForRepo({
|
|
7882
|
+
owner,
|
|
7883
|
+
repo,
|
|
7884
|
+
branch,
|
|
7885
|
+
per_page: 60
|
|
7886
|
+
});
|
|
7887
|
+
return data.workflow_runs.filter((r) => r.status === "completed").map((r) => ({ name: r.name ?? "", headSha: r.head_sha })).filter((c) => c.name !== "");
|
|
7888
|
+
}
|
|
7889
|
+
async function runBranchProtectionCheck(explicitRepo, options = {}) {
|
|
7809
7890
|
const { owner, repo } = await resolveRepo(explicitRepo);
|
|
7810
7891
|
const octokit = new Octokit2({
|
|
7811
7892
|
auth: tokenFromEnv(),
|
|
@@ -7851,12 +7932,39 @@ async function runBranchProtectionCheck(explicitRepo) {
|
|
|
7851
7932
|
);
|
|
7852
7933
|
process.exit(1);
|
|
7853
7934
|
}
|
|
7935
|
+
if (findings.length > 0 && options.fix) {
|
|
7936
|
+
const defaultBranch = (await octokit.repos.get({ owner, repo })).data.default_branch;
|
|
7937
|
+
const observed = await observedChecks(octokit, owner, repo, defaultBranch);
|
|
7938
|
+
const plans = audited.map((branch) => planProtection(branch, true, observed));
|
|
7939
|
+
console.log(`Backfilling protection on ${owner}/${repo}:
|
|
7940
|
+
`);
|
|
7941
|
+
console.log(formatPlans(plans));
|
|
7942
|
+
let applied = 0;
|
|
7943
|
+
for (const plan of plans.filter((p) => p.action === "apply")) {
|
|
7944
|
+
await octokit.repos.updateBranchProtection({
|
|
7945
|
+
owner,
|
|
7946
|
+
repo,
|
|
7947
|
+
branch: plan.branch,
|
|
7948
|
+
...protectionParamsFor(plan.contexts)
|
|
7949
|
+
});
|
|
7950
|
+
applied += 1;
|
|
7951
|
+
}
|
|
7952
|
+
if (applied === 0) {
|
|
7953
|
+
console.error(
|
|
7954
|
+
"\n\u2717 nothing applied \u2014 no branch had a check reporting consistently enough to require.\n Protection with an empty context list is worse than none: a PR reads CLEAN before\n any run registers. Get CI reporting on this repo first."
|
|
7955
|
+
);
|
|
7956
|
+
process.exit(1);
|
|
7957
|
+
}
|
|
7958
|
+
console.log(`
|
|
7959
|
+
\u2713 protection applied to ${applied} branch(es). Re-run without --fix to verify.`);
|
|
7960
|
+
return;
|
|
7961
|
+
}
|
|
7854
7962
|
if (findings.length > 0) {
|
|
7855
7963
|
console.error(`\u2717 branch-protection guard: ${owner}/${repo}
|
|
7856
7964
|
`);
|
|
7857
7965
|
console.error(formatFindings(findings));
|
|
7858
7966
|
console.error(
|
|
7859
|
-
"\n Protection is applied once at scaffold time and skipped silently on a 403 (#715)
|
|
7967
|
+
"\n Protection is applied once at scaffold time and skipped silently on a 403 (#715),\n and standalone plugin repos are created outside the CLI so it never runs at all\n (#714). Re-run with --fix to backfill it from the checks this repo actually reports."
|
|
7860
7968
|
);
|
|
7861
7969
|
process.exit(1);
|
|
7862
7970
|
}
|
|
@@ -8269,8 +8377,11 @@ checkCommand.command("plugin-terraform").description("Verify every template-owne
|
|
|
8269
8377
|
});
|
|
8270
8378
|
checkCommand.command("branch-protection").description(
|
|
8271
8379
|
"Verify dev/staging/main are actually protected \u2014 scaffolding skips this on a 403 (#715)"
|
|
8272
|
-
).option("--repo <owner/name>", "Repo to audit; defaults to this checkout's origin remote").
|
|
8273
|
-
|
|
8380
|
+
).option("--repo <owner/name>", "Repo to audit; defaults to this checkout's origin remote").option(
|
|
8381
|
+
"--fix",
|
|
8382
|
+
"Backfill protection from the checks this repo actually reports (#714, #715). Refuses to apply an empty required-check list, which would look protected and admit anything."
|
|
8383
|
+
).action(async (opts) => {
|
|
8384
|
+
await runBranchProtectionCheck(opts.repo, { fix: opts.fix });
|
|
8274
8385
|
});
|
|
8275
8386
|
function rawArgsAfter(subcommand) {
|
|
8276
8387
|
const at = process.argv.indexOf(subcommand);
|