@codyswann/lisa 3.62.0 → 3.64.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/all/copy-overwrite/scripts/lisa-gates.mjs +223 -10
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +325 -32
- package/dist/cli/gate-report-templates.js +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +8 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +16 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/health/ruleset-inspection.d.ts.map +1 -1
- package/dist/health/ruleset-inspection.js +73 -6
- package/dist/health/ruleset-inspection.js.map +1 -1
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/lisa/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/lisa-agy/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/lisa-copilot/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/lisa-cursor/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +46 -7
- package/plugins/src/base/skills/lisa-setup-github-repo/SKILL.md +7 -3
- package/scripts/check-required-check-promotions.mjs +69 -4
- package/scripts/lisa-github-repo-setup.sh +64 -5
- package/scripts/lisa-github-rulesets.sh +201 -21
- package/scripts/lisa-ruleset-payload.mjs +376 -0
- package/all/github-rulesets/base.json +0 -64
|
@@ -44,6 +44,25 @@ PROJECT_PATH=""
|
|
|
44
44
|
UNCHANGED_COUNT=0
|
|
45
45
|
APPLIED_PAYLOAD=""
|
|
46
46
|
|
|
47
|
+
# The temp file holding the config-derived `base` ruleset, cleaned up on exit.
|
|
48
|
+
GENERATED_RULESET=""
|
|
49
|
+
|
|
50
|
+
# Delete the generated ruleset, but ONLY when the top-level shell exits.
|
|
51
|
+
#
|
|
52
|
+
# A command substitution runs in a subshell that inherits this EXIT trap and
|
|
53
|
+
# fires it on the subshell's exit. Without the pid guard the very next
|
|
54
|
+
# `$(collect_templates ...)` deleted the file that had just been generated, and
|
|
55
|
+
# the applier then read an empty template, resolved its name to the empty
|
|
56
|
+
# string, and reported "Skipping ruleset '' — no applicable rules" while
|
|
57
|
+
# exiting 0. That is the whole failure mode this change exists to prevent, one
|
|
58
|
+
# layer down: a run that reports success having applied no branch protection.
|
|
59
|
+
cleanup_generated_ruleset() {
|
|
60
|
+
[[ "$BASHPID" == "$$" ]] || return 0
|
|
61
|
+
[[ -n "$GENERATED_RULESET" ]] && rm -f "$GENERATED_RULESET"
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
64
|
+
trap cleanup_generated_ruleset EXIT
|
|
65
|
+
|
|
47
66
|
# Project type hierarchy (child -> parent)
|
|
48
67
|
# Using a function to avoid associative array issues with set -u
|
|
49
68
|
get_parent_type() {
|
|
@@ -141,6 +160,14 @@ check_prerequisites() {
|
|
|
141
160
|
missing+=("jq")
|
|
142
161
|
fi
|
|
143
162
|
|
|
163
|
+
# The `base` ruleset is no longer a shipped JSON file; it is generated from
|
|
164
|
+
# .lisa.config.json by scripts/lisa-ruleset-payload.mjs. Without node there
|
|
165
|
+
# is no branch protection to apply at all, so this is a hard prerequisite
|
|
166
|
+
# rather than a degraded mode.
|
|
167
|
+
if ! command -v node &> /dev/null; then
|
|
168
|
+
missing+=("node")
|
|
169
|
+
fi
|
|
170
|
+
|
|
144
171
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
145
172
|
log_error "Missing required tools: ${missing[*]}"
|
|
146
173
|
exit 1
|
|
@@ -362,14 +389,22 @@ strip_config_dropped_checks() {
|
|
|
362
389
|
# report, and a required check that never reports blocks every pull request
|
|
363
390
|
# forever (the #2476 "aspirational seed a guard then trusts" defect).
|
|
364
391
|
#
|
|
365
|
-
# Keyed by ruleset name, because more than one
|
|
366
|
-
# required_status_checks rule (`base` and `quality checks` both
|
|
367
|
-
# unkeyed list could not say which one it meant:
|
|
368
|
-
# { "github": { "rulesets": { "
|
|
392
|
+
# Keyed by ruleset name, because more than one ruleset carries a
|
|
393
|
+
# required_status_checks rule (the generated `base` and `quality checks` both
|
|
394
|
+
# do) and an unkeyed list could not say which one it meant:
|
|
395
|
+
# { "github": { "rulesets": { "requiredChecks": {
|
|
369
396
|
# "quality checks": [
|
|
370
397
|
# { "context": "🧩 Plugin artifacts match source", "integration_id": 15368 }
|
|
371
398
|
# ] } } } }
|
|
372
399
|
#
|
|
400
|
+
# `requiredChecks` is DECLARATIVE where the retired `addRequiredChecks` was
|
|
401
|
+
# additive: naming a ruleset here also stops the applier unioning that
|
|
402
|
+
# ruleset's LIVE required list back into the payload. Additive-only could add a
|
|
403
|
+
# context and never remove one, so a required check outlived the job that
|
|
404
|
+
# posted it and the only way to drop it was the admin console. `addRequiredChecks`
|
|
405
|
+
# is still read so installed projects keep applying, with a warning naming its
|
|
406
|
+
# replacement.
|
|
407
|
+
#
|
|
373
408
|
# `integration_id` is optional and defaults to GitHub Actions. Contexts already
|
|
374
409
|
# present are not duplicated, and a ruleset with no required_status_checks rule
|
|
375
410
|
# gets one created so an addition is never silently dropped.
|
|
@@ -380,24 +415,62 @@ strip_config_dropped_checks() {
|
|
|
380
415
|
# on a repository with no workflows can never report, and would block every pull
|
|
381
416
|
# request), and naming the same context in both lists is operator error whose
|
|
382
417
|
# safe resolution is to drop it rather than to require it.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
418
|
+
|
|
419
|
+
# The declared required-check list for one ruleset, or "[]" when none.
|
|
420
|
+
# Prefers the declarative key and falls back to the retired additive one.
|
|
421
|
+
config_required_checks() {
|
|
422
|
+
local project_path="$1"
|
|
423
|
+
local ruleset_name="$2"
|
|
387
424
|
local config="$project_path/.lisa.config.json"
|
|
388
425
|
|
|
389
426
|
if [[ ! -f "$config" ]]; then
|
|
390
|
-
echo "
|
|
427
|
+
echo "[]"
|
|
391
428
|
return 0
|
|
392
429
|
fi
|
|
393
430
|
|
|
394
|
-
local
|
|
395
|
-
if !
|
|
396
|
-
'.github.rulesets.
|
|
431
|
+
local declared
|
|
432
|
+
if ! declared=$(jq -c --arg name "$ruleset_name" \
|
|
433
|
+
'.github.rulesets.requiredChecks[$name] // null' "$config" 2>/dev/null); then
|
|
397
434
|
log_warning ".lisa.config.json could not be parsed — ignoring github.rulesets overrides" >&2
|
|
398
|
-
|
|
435
|
+
echo "[]"
|
|
436
|
+
return 0
|
|
437
|
+
fi
|
|
438
|
+
|
|
439
|
+
if [[ "$declared" != "null" ]]; then
|
|
440
|
+
echo "$declared"
|
|
441
|
+
return 0
|
|
399
442
|
fi
|
|
400
443
|
|
|
444
|
+
local legacy
|
|
445
|
+
legacy=$(jq -c --arg name "$ruleset_name" \
|
|
446
|
+
'.github.rulesets.addRequiredChecks[$name] // []' "$config" 2>/dev/null) || legacy="[]"
|
|
447
|
+
if [[ "$legacy" != "[]" && "$legacy" != "null" ]]; then
|
|
448
|
+
log_warning "github.rulesets.addRequiredChecks is retired — rename it to requiredChecks, which can also STOP requiring a context" >&2
|
|
449
|
+
fi
|
|
450
|
+
echo "$legacy"
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# True when config states this ruleset's required list, so the live list must
|
|
454
|
+
# NOT be unioned back in. That union is what made removal impossible.
|
|
455
|
+
ruleset_checks_are_declared() {
|
|
456
|
+
local project_path="$1"
|
|
457
|
+
local ruleset_name="$2"
|
|
458
|
+
local config="$project_path/.lisa.config.json"
|
|
459
|
+
|
|
460
|
+
[[ -f "$config" ]] || return 1
|
|
461
|
+
jq -e --arg name "$ruleset_name" \
|
|
462
|
+
'(.github.rulesets.requiredChecks[$name] // null) | type == "array"' \
|
|
463
|
+
"$config" &> /dev/null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
add_config_required_checks() {
|
|
467
|
+
local json="$1"
|
|
468
|
+
local project_path="$2"
|
|
469
|
+
local ruleset_name="$3"
|
|
470
|
+
|
|
471
|
+
local added
|
|
472
|
+
added=$(config_required_checks "$project_path" "$ruleset_name")
|
|
473
|
+
|
|
401
474
|
if [[ "$added" == "[]" || "$added" == "null" ]]; then
|
|
402
475
|
echo "$json"
|
|
403
476
|
return 0
|
|
@@ -526,6 +599,27 @@ ruleset_is_current() {
|
|
|
526
599
|
covers($live; $want)' > /dev/null
|
|
527
600
|
}
|
|
528
601
|
|
|
602
|
+
# The contexts the live ruleset requires that the outgoing payload does not.
|
|
603
|
+
# The mirror of ruleset_added_contexts, and it exists because a declarative
|
|
604
|
+
# required-check list can now REMOVE a requirement. Losing a protection is the
|
|
605
|
+
# more consequential of the two directions, so it is never merely "applied".
|
|
606
|
+
ruleset_removed_contexts() {
|
|
607
|
+
local live="$1"
|
|
608
|
+
local payload="$2"
|
|
609
|
+
|
|
610
|
+
jq -r -n --argjson live "${live:-null}" --argjson want "$payload" '
|
|
611
|
+
def contexts:
|
|
612
|
+
[ (.rules // [])[]
|
|
613
|
+
| select(.type == "required_status_checks")
|
|
614
|
+
| (.parameters.required_status_checks // [])[]
|
|
615
|
+
| .context ];
|
|
616
|
+
|
|
617
|
+
($want | contexts) as $want_contexts
|
|
618
|
+
| (if $live == null then [] else ($live | contexts) end)
|
|
619
|
+
| map(select(. as $context | ($want_contexts | index($context)) | not))
|
|
620
|
+
| .[]'
|
|
621
|
+
}
|
|
622
|
+
|
|
529
623
|
# Print one line per context this run makes blocking. Reads the payload that
|
|
530
624
|
# GitHub actually ACCEPTED, so a context dropped by the integration fallback is
|
|
531
625
|
# never reported as added.
|
|
@@ -539,6 +633,20 @@ report_added_contexts() {
|
|
|
539
633
|
done < <(ruleset_added_contexts "$live" "$payload")
|
|
540
634
|
}
|
|
541
635
|
|
|
636
|
+
# Print one line per context this run stops requiring, by name. A declarative
|
|
637
|
+
# list that quietly dropped a check would read in the audit log as a routine
|
|
638
|
+
# reconciliation, which is exactly how a guarantee disappears without anyone
|
|
639
|
+
# deciding to give it up.
|
|
640
|
+
report_removed_contexts() {
|
|
641
|
+
local live="$1"
|
|
642
|
+
local payload="$2"
|
|
643
|
+
|
|
644
|
+
local context
|
|
645
|
+
while IFS= read -r context; do
|
|
646
|
+
[[ -n "$context" ]] && log_warning " - no longer required: $context (nothing in .lisa.config.json declares it)"
|
|
647
|
+
done < <(ruleset_removed_contexts "$live" "$payload")
|
|
648
|
+
}
|
|
649
|
+
|
|
542
650
|
apply_ruleset() {
|
|
543
651
|
local repo="$1"
|
|
544
652
|
local template_file="$2"
|
|
@@ -570,7 +678,29 @@ apply_ruleset() {
|
|
|
570
678
|
log_warning "Could not read existing ruleset '$ruleset_name' details — refusing to silently replace required checks"
|
|
571
679
|
return 1
|
|
572
680
|
fi
|
|
573
|
-
|
|
681
|
+
# Union the live required list back in UNLESS config states it. Preserving
|
|
682
|
+
# it is the right default — the live list carries external app checks
|
|
683
|
+
# nothing declares, and replacing it silently would strip protection. But a
|
|
684
|
+
# project that names a ruleset in `requiredChecks` has said what it wants
|
|
685
|
+
# required there, and honouring the union in that case is precisely what
|
|
686
|
+
# made `addRequiredChecks` unable to remove anything.
|
|
687
|
+
#
|
|
688
|
+
# The generated `base` ruleset gets NO exemption from this. It was tempting:
|
|
689
|
+
# its payload is entirely config-derived, so unioning the live list back in
|
|
690
|
+
# means an await removed from config does not stop being required. But the
|
|
691
|
+
# exemption would make removal unconditional on every run of a script
|
|
692
|
+
# `lisa-github-repo-setup.sh` invokes with `--yes`, so any context required
|
|
693
|
+
# today that no gate awaits and no `requiredChecks.base` names would be
|
|
694
|
+
# deleted with no operator ever opting in. That is the mirror of the rule
|
|
695
|
+
# `lisa-reconcile-policy.mjs` states in its own header — an EXTRA context is
|
|
696
|
+
# reported, never removed without `--prune` — and a protection lost by
|
|
697
|
+
# default reads in the audit log as a routine reconciliation. So `base`
|
|
698
|
+
# becomes declarative the same way every other ruleset does: by being named.
|
|
699
|
+
if ruleset_checks_are_declared "$project_path" "$ruleset_name"; then
|
|
700
|
+
log_verbose "Required checks for '$ruleset_name' are declared in .lisa.config.json — the live list is not preserved"
|
|
701
|
+
else
|
|
702
|
+
clean_template=$(preserve_live_required_checks "$live" "$clean_template")
|
|
703
|
+
fi
|
|
574
704
|
fi
|
|
575
705
|
|
|
576
706
|
# Idempotence: a run that would change nothing must say so and send nothing,
|
|
@@ -588,6 +718,7 @@ apply_ruleset() {
|
|
|
588
718
|
log_info "[DRY RUN] Would create ruleset '$ruleset_name'"
|
|
589
719
|
fi
|
|
590
720
|
report_added_contexts "$live" "$clean_template"
|
|
721
|
+
report_removed_contexts "$live" "$clean_template"
|
|
591
722
|
log_verbose "Template: $template_file"
|
|
592
723
|
return 0
|
|
593
724
|
fi
|
|
@@ -601,6 +732,7 @@ apply_ruleset() {
|
|
|
601
732
|
APPLIED_PAYLOAD=""
|
|
602
733
|
if apply_with_integration_fallback "$repo" "$ruleset_name" "$clean_template" "$existing_id"; then
|
|
603
734
|
report_added_contexts "$live" "${APPLIED_PAYLOAD:-$clean_template}"
|
|
735
|
+
report_removed_contexts "$live" "${APPLIED_PAYLOAD:-$clean_template}"
|
|
604
736
|
return 0
|
|
605
737
|
fi
|
|
606
738
|
log_error "Failed to apply ruleset '$ruleset_name'"
|
|
@@ -690,6 +822,50 @@ apply_with_integration_fallback() {
|
|
|
690
822
|
# Main Logic
|
|
691
823
|
##############################################################################
|
|
692
824
|
|
|
825
|
+
# Build the `base` ruleset from .lisa.config.json into a temp file.
|
|
826
|
+
#
|
|
827
|
+
# `all/github-rulesets/base.json` used to be a shipped template here. Seven of
|
|
828
|
+
# its fields were already declared in `.lisa.config.json`, so two writers set
|
|
829
|
+
# the same settings and the last one won; four more could not be declared at
|
|
830
|
+
# all; and it pinned two vendor status checks every repository inherited and
|
|
831
|
+
# none could drop. It is now generated, from one declaration, per project.
|
|
832
|
+
#
|
|
833
|
+
# A failure here is fatal rather than a skip. The alternative is applying every
|
|
834
|
+
# OTHER ruleset and reporting success while the repository has no branch
|
|
835
|
+
# protection, which is the shape of failure this whole change exists to stop.
|
|
836
|
+
generate_base_ruleset() {
|
|
837
|
+
local project_path="$1"
|
|
838
|
+
local generator="$LISA_ROOT/scripts/lisa-ruleset-payload.mjs"
|
|
839
|
+
local out
|
|
840
|
+
|
|
841
|
+
if [[ ! -f "$generator" ]]; then
|
|
842
|
+
log_error "Missing $generator — the base ruleset is generated from .lisa.config.json and cannot be applied without it"
|
|
843
|
+
return 1
|
|
844
|
+
fi
|
|
845
|
+
|
|
846
|
+
out="$(mktemp)"
|
|
847
|
+
if ! node "$generator" --project="$project_path" > "$out" 2>"$out.err"; then
|
|
848
|
+
log_error "Could not build the base ruleset from .lisa.config.json:"
|
|
849
|
+
log_error "$(cat "$out.err")"
|
|
850
|
+
rm -f "$out" "$out.err"
|
|
851
|
+
return 1
|
|
852
|
+
fi
|
|
853
|
+
rm -f "$out.err"
|
|
854
|
+
|
|
855
|
+
# A generator that exits 0 having printed nothing is the failure this guard
|
|
856
|
+
# exists for: the applier would read an empty template, resolve its name to
|
|
857
|
+
# the empty string, skip it as "no applicable rules", and exit 0 having
|
|
858
|
+
# applied no branch protection at all. Measured — a symlinked path made the
|
|
859
|
+
# generator's own entry-point guard false. Never trust the exit code alone.
|
|
860
|
+
if ! jq -e '(.name | type == "string" and length > 0) and ((.rules // []) | length) > 0' "$out" &> /dev/null; then
|
|
861
|
+
log_error "The generated base ruleset is empty or has no name — refusing to continue with no branch protection to apply"
|
|
862
|
+
rm -f "$out"
|
|
863
|
+
return 1
|
|
864
|
+
fi
|
|
865
|
+
|
|
866
|
+
echo "$out"
|
|
867
|
+
}
|
|
868
|
+
|
|
693
869
|
collect_templates() {
|
|
694
870
|
local -a types=("$@")
|
|
695
871
|
local -a templates=()
|
|
@@ -789,18 +965,22 @@ main() {
|
|
|
789
965
|
log_info "Detected types: ${expanded_types[*]}"
|
|
790
966
|
fi
|
|
791
967
|
|
|
968
|
+
# Build the config-derived base ruleset first, so it is applied before any
|
|
969
|
+
# template and a failure to build it stops the run before anything is written.
|
|
970
|
+
if ! GENERATED_RULESET=$(generate_base_ruleset "$PROJECT_PATH"); then
|
|
971
|
+
exit 1
|
|
972
|
+
fi
|
|
973
|
+
log_verbose "Generated base ruleset from .lisa.config.json"
|
|
974
|
+
|
|
792
975
|
# Collect templates
|
|
793
976
|
local templates_str
|
|
794
977
|
templates_str=$(collect_templates "${expanded_types[@]}")
|
|
795
978
|
|
|
796
|
-
local -a templates=()
|
|
979
|
+
local -a templates=("$GENERATED_RULESET")
|
|
980
|
+
local -a extra_templates=()
|
|
797
981
|
if [[ -n "$templates_str" ]]; then
|
|
798
|
-
read -ra
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if [[ ${#templates[@]} -eq 0 ]]; then
|
|
802
|
-
log_warning "No ruleset templates found"
|
|
803
|
-
exit 0
|
|
982
|
+
read -ra extra_templates <<< "$templates_str"
|
|
983
|
+
templates+=("${extra_templates[@]}")
|
|
804
984
|
fi
|
|
805
985
|
|
|
806
986
|
log_info "Found ${#templates[@]} ruleset template(s)"
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Build the `base` branch-ruleset payload from resolved Lisa config.
|
|
5
|
+
*
|
|
6
|
+
* This replaces `all/github-rulesets/base.json`, a shipped template that did
|
|
7
|
+
* two jobs badly.
|
|
8
|
+
*
|
|
9
|
+
* Seven of its fields were ALREADY declared in `.lisa.config.json`'s `policy`
|
|
10
|
+
* block — deletion, non-fast-forward, thread resolution, stale-review
|
|
11
|
+
* dismissal, last-push approval, allowed merge methods, strict status checks.
|
|
12
|
+
* Two writers therefore set the same settings from two files, whichever ran
|
|
13
|
+
* last won, and nothing compared them. Four more fields — `bypass_actors`, the
|
|
14
|
+
* `ref_name` conditions, `required_approving_review_count`, `enforcement` —
|
|
15
|
+
* could not be declared in config at all, so the template's values were a
|
|
16
|
+
* fleet-wide lock no project could override.
|
|
17
|
+
*
|
|
18
|
+
* And it pinned two vendor status checks by integration id, which every
|
|
19
|
+
* repository inherited and none could drop, because the only config hook was
|
|
20
|
+
* `github.rulesets.addRequiredChecks` — additive by construction. A project
|
|
21
|
+
* proving credential leakage with a different scanner had no way to say so,
|
|
22
|
+
* and a required context that nothing posts blocks every pull request in that
|
|
23
|
+
* repository forever.
|
|
24
|
+
*
|
|
25
|
+
* ## Where the required checks come from now
|
|
26
|
+
*
|
|
27
|
+
* From `await` gate declarations, a mode `lisa-gates.mjs` has always carried
|
|
28
|
+
* and nothing used. An awaited gate names a signal somebody else posts:
|
|
29
|
+
*
|
|
30
|
+
* ```json
|
|
31
|
+
* "credential-leakage": {
|
|
32
|
+
* "pull-request": {
|
|
33
|
+
* "level": "required",
|
|
34
|
+
* "await": "GitGuardian Security Checks",
|
|
35
|
+
* "posted_by": 46505
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* `contextsFor` already emits an awaited gate's context verbatim rather than
|
|
41
|
+
* deriving `<workflow> / <label>`, so the same declaration feeds the branch
|
|
42
|
+
* ruleset, the CI façade, and `lisa-reconcile-policy.mjs`'s drift comparison.
|
|
43
|
+
* Declaring nothing requires nothing: that is the override the template could
|
|
44
|
+
* not express.
|
|
45
|
+
*
|
|
46
|
+
* ## Defaults are the old template, minus its vendors
|
|
47
|
+
*
|
|
48
|
+
* A project with no `policy` block gets exactly the ruleset `base.json`
|
|
49
|
+
* described, EXCEPT its two vendor checks. That is the deliberate difference:
|
|
50
|
+
* the shape was never the objectionable part, the un-droppable vendor lock was.
|
|
51
|
+
*
|
|
52
|
+
* Usage:
|
|
53
|
+
* lisa-ruleset-payload.mjs [--project=PATH] [--moment=pull-request]
|
|
54
|
+
* @module lisa-ruleset-payload
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { invokedAsScript } from "../all/copy-overwrite/scripts/lib/invoked-as-script.mjs";
|
|
58
|
+
import {
|
|
59
|
+
readGates,
|
|
60
|
+
resolveMoment,
|
|
61
|
+
} from "../all/copy-overwrite/scripts/lisa-gates.mjs";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The ruleset this module builds.
|
|
65
|
+
*
|
|
66
|
+
* Named rather than inferred because `lisa-reconcile-policy.mjs` compares the
|
|
67
|
+
* declared `policy.ruleset` block against ONE live ruleset's shape, and a
|
|
68
|
+
* comparison that guessed which one would report drift against whichever
|
|
69
|
+
* ruleset happened to sort first.
|
|
70
|
+
*/
|
|
71
|
+
export const POLICY_RULESET_NAME = "base";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The ruleset shape a project inherits when it declares none.
|
|
75
|
+
*
|
|
76
|
+
* Field-for-field the retired `all/github-rulesets/base.json`, so deleting the
|
|
77
|
+
* template changes nothing for a project that declared nothing. The one
|
|
78
|
+
* omission is its `required_status_checks` list: those two contexts are now
|
|
79
|
+
* `await` declarations, and a default would put the lock straight back.
|
|
80
|
+
*/
|
|
81
|
+
export const RULESET_DEFAULTS = Object.freeze({
|
|
82
|
+
enforcement: "active",
|
|
83
|
+
include_refs: Object.freeze([
|
|
84
|
+
"~DEFAULT_BRANCH",
|
|
85
|
+
"refs/heads/dev",
|
|
86
|
+
"refs/heads/staging",
|
|
87
|
+
"refs/heads/main",
|
|
88
|
+
]),
|
|
89
|
+
exclude_refs: Object.freeze([]),
|
|
90
|
+
bypass_actors: Object.freeze([
|
|
91
|
+
Object.freeze({
|
|
92
|
+
actor_id: null,
|
|
93
|
+
actor_type: "DeployKey",
|
|
94
|
+
bypass_mode: "always",
|
|
95
|
+
}),
|
|
96
|
+
Object.freeze({
|
|
97
|
+
actor_id: 5,
|
|
98
|
+
actor_type: "RepositoryRole",
|
|
99
|
+
bypass_mode: "always",
|
|
100
|
+
}),
|
|
101
|
+
]),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** Pull-request policy defaults, matching the retired template. */
|
|
105
|
+
export const REVIEW_DEFAULTS = Object.freeze({
|
|
106
|
+
required_approving_review_count: 0,
|
|
107
|
+
require_code_owner_review: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** Branch-protection defaults, matching the retired template. */
|
|
111
|
+
export const PROTECT_DEFAULTS = Object.freeze({
|
|
112
|
+
deletion: true,
|
|
113
|
+
force_push: true,
|
|
114
|
+
conversation_resolution: true,
|
|
115
|
+
dismiss_stale_reviews: false,
|
|
116
|
+
require_last_push_approval: false,
|
|
117
|
+
up_to_date_before_merge: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Merge-method defaults, matching the retired template. */
|
|
121
|
+
export const MERGE_DEFAULTS = Object.freeze({
|
|
122
|
+
merge_commit: true,
|
|
123
|
+
squash: false,
|
|
124
|
+
rebase: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** GitHub's spelling of each allowed merge method. */
|
|
128
|
+
const MERGE_METHODS = Object.freeze({
|
|
129
|
+
merge_commit: "merge",
|
|
130
|
+
squash: "squash",
|
|
131
|
+
rebase: "rebase",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read a declared value, falling back to the default.
|
|
136
|
+
* @param {object|undefined} section The declared section.
|
|
137
|
+
* @param {string} field The field name.
|
|
138
|
+
* @param {object} defaults The defaults for that section.
|
|
139
|
+
* @returns {*} The resolved value.
|
|
140
|
+
*/
|
|
141
|
+
function resolved(section, field, defaults) {
|
|
142
|
+
const declared = section?.[field];
|
|
143
|
+
return declared === undefined ? defaults[field] : declared;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The awaited signals a project requires at one moment.
|
|
148
|
+
*
|
|
149
|
+
* Only `required` gates. An awaited gate at `optional` says the signal exists
|
|
150
|
+
* and is read, not that a pull request waits for it, and promoting it to a
|
|
151
|
+
* required context would turn advisory information into a merge block nobody
|
|
152
|
+
* declared.
|
|
153
|
+
* @param {object} gates The gates block.
|
|
154
|
+
* @param {string} moment The moment to resolve.
|
|
155
|
+
* @returns {Array<{context: string, integration_id?: number}>} Required checks.
|
|
156
|
+
*/
|
|
157
|
+
export function awaitedChecks(gates, moment) {
|
|
158
|
+
return resolveMoment({ gates, moment })
|
|
159
|
+
.filter(gate => gate.level === "required" && gate.mode === "await")
|
|
160
|
+
.filter(gate => Boolean(gate.awaits))
|
|
161
|
+
.map(gate =>
|
|
162
|
+
gate.postedBy === null
|
|
163
|
+
? { context: gate.awaits }
|
|
164
|
+
: { context: gate.awaits, integration_id: gate.postedBy }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Collapse exact duplicate awaited checks, and refuse conflicting ones.
|
|
170
|
+
*
|
|
171
|
+
* A ruleset carries one entry per context. Two required gates awaiting the same
|
|
172
|
+
* signal is legitimate, so an exact duplicate — same context, same pin, both
|
|
173
|
+
* unpinned included — collapses to one entry. Two DIFFERENT pins for one
|
|
174
|
+
* context cannot both be honoured, and keeping whichever came first would
|
|
175
|
+
* discard a declaration silently: the repository would then require the context
|
|
176
|
+
* pinned to an app the project never named for it.
|
|
177
|
+
*
|
|
178
|
+
* `validateGates` refuses this too. Both, because this function is what
|
|
179
|
+
* actually writes the payload, and a writer that trusts its caller to have
|
|
180
|
+
* validated is a writer that ships the conflict the day some path skips
|
|
181
|
+
* validation.
|
|
182
|
+
* @param {Array<{context: string, integration_id?: number}>} checks Awaited checks.
|
|
183
|
+
* @returns {Array<{context: string, integration_id?: number}>} One per context.
|
|
184
|
+
* @throws {Error} When one context is declared with two different pins.
|
|
185
|
+
*/
|
|
186
|
+
function collapseAwaited(checks) {
|
|
187
|
+
const byContext = new Map();
|
|
188
|
+
for (const check of checks) {
|
|
189
|
+
const previous = byContext.get(check.context);
|
|
190
|
+
if (previous === undefined) {
|
|
191
|
+
byContext.set(check.context, check);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if ((previous.integration_id ?? null) === (check.integration_id ?? null)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
throw new Error(
|
|
198
|
+
`"${check.context}" is awaited by two required gates naming different ` +
|
|
199
|
+
`apps (${JSON.stringify(check.integration_id ?? null)} and ` +
|
|
200
|
+
`${JSON.stringify(previous.integration_id ?? null)}). A ruleset carries ` +
|
|
201
|
+
`one entry per context, so one pin would be dropped silently. An ` +
|
|
202
|
+
`omitted posted_by means unpinned, which is a different requirement ` +
|
|
203
|
+
`from a pinned one.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return [...byContext.values()];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The `pull_request` rule, built from `policy.protect` and `policy.review`.
|
|
211
|
+
* @param {object} policy The policy block.
|
|
212
|
+
* @returns {object} The rule.
|
|
213
|
+
*/
|
|
214
|
+
function pullRequestRule(policy) {
|
|
215
|
+
const merge = policy?.merge ?? {};
|
|
216
|
+
const allowed = Object.entries(MERGE_METHODS)
|
|
217
|
+
.filter(([field]) => resolved(merge, field, MERGE_DEFAULTS) === true)
|
|
218
|
+
.map(([, method]) => method);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
type: "pull_request",
|
|
222
|
+
parameters: {
|
|
223
|
+
required_approving_review_count: resolved(
|
|
224
|
+
policy?.review,
|
|
225
|
+
"required_approving_review_count",
|
|
226
|
+
REVIEW_DEFAULTS
|
|
227
|
+
),
|
|
228
|
+
dismiss_stale_reviews_on_push: resolved(
|
|
229
|
+
policy?.protect,
|
|
230
|
+
"dismiss_stale_reviews",
|
|
231
|
+
PROTECT_DEFAULTS
|
|
232
|
+
),
|
|
233
|
+
require_code_owner_review: resolved(
|
|
234
|
+
policy?.review,
|
|
235
|
+
"require_code_owner_review",
|
|
236
|
+
REVIEW_DEFAULTS
|
|
237
|
+
),
|
|
238
|
+
require_last_push_approval: resolved(
|
|
239
|
+
policy?.protect,
|
|
240
|
+
"require_last_push_approval",
|
|
241
|
+
PROTECT_DEFAULTS
|
|
242
|
+
),
|
|
243
|
+
required_review_thread_resolution: resolved(
|
|
244
|
+
policy?.protect,
|
|
245
|
+
"conversation_resolution",
|
|
246
|
+
PROTECT_DEFAULTS
|
|
247
|
+
),
|
|
248
|
+
allowed_merge_methods: allowed,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build the ruleset payload GitHub is sent.
|
|
255
|
+
*
|
|
256
|
+
* Deliberately reads only `gates` and `policy`. The per-ruleset opt-ins —
|
|
257
|
+
* `github.rulesets.requiredChecks` and `dropRequiredChecks` — are applied by
|
|
258
|
+
* `scripts/lisa-github-rulesets.sh` to EVERY ruleset it sends, this one
|
|
259
|
+
* included, and applying them here as well would mean two writers with two
|
|
260
|
+
* different defaults for `integration_id` racing over the same list.
|
|
261
|
+
* @param {object} options Build inputs.
|
|
262
|
+
* @param {object} [options.gates] The gates block.
|
|
263
|
+
* @param {object} [options.policy] The policy block.
|
|
264
|
+
* @param {string} [options.moment] Moment to derive awaited checks for.
|
|
265
|
+
* @returns {object} A create/update payload for the `base` ruleset.
|
|
266
|
+
*/
|
|
267
|
+
export function buildRulesetPayload({
|
|
268
|
+
gates = {},
|
|
269
|
+
policy = {},
|
|
270
|
+
moment = "pull-request",
|
|
271
|
+
} = {}) {
|
|
272
|
+
const protect = policy?.protect ?? {};
|
|
273
|
+
const history = policy?.history ?? {};
|
|
274
|
+
const ruleset = policy?.ruleset ?? {};
|
|
275
|
+
|
|
276
|
+
const rules = [];
|
|
277
|
+
if (resolved(protect, "deletion", PROTECT_DEFAULTS)) {
|
|
278
|
+
rules.push({ type: "deletion" });
|
|
279
|
+
}
|
|
280
|
+
if (resolved(protect, "force_push", PROTECT_DEFAULTS)) {
|
|
281
|
+
rules.push({ type: "non_fast_forward" });
|
|
282
|
+
}
|
|
283
|
+
if (history.linear === true) rules.push({ type: "required_linear_history" });
|
|
284
|
+
if (history.signed_commits === true) {
|
|
285
|
+
rules.push({ type: "required_signatures" });
|
|
286
|
+
}
|
|
287
|
+
rules.push(pullRequestRule(policy));
|
|
288
|
+
|
|
289
|
+
const checks = collapseAwaited(awaitedChecks(gates, moment));
|
|
290
|
+
|
|
291
|
+
// An empty required_status_checks rule is not a weaker rule, it is a rule
|
|
292
|
+
// GitHub rejects. Omitting it is also what makes "this project requires no
|
|
293
|
+
// external signal" representable at all, which the template could not say.
|
|
294
|
+
if (checks.length > 0) {
|
|
295
|
+
rules.push({
|
|
296
|
+
type: "required_status_checks",
|
|
297
|
+
parameters: {
|
|
298
|
+
strict_required_status_checks_policy: resolved(
|
|
299
|
+
protect,
|
|
300
|
+
"up_to_date_before_merge",
|
|
301
|
+
PROTECT_DEFAULTS
|
|
302
|
+
),
|
|
303
|
+
do_not_enforce_on_create: false,
|
|
304
|
+
required_status_checks: checks,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
name: POLICY_RULESET_NAME,
|
|
311
|
+
target: "branch",
|
|
312
|
+
enforcement: resolved(ruleset, "enforcement", RULESET_DEFAULTS),
|
|
313
|
+
conditions: {
|
|
314
|
+
ref_name: {
|
|
315
|
+
exclude: [...resolved(ruleset, "exclude_refs", RULESET_DEFAULTS)],
|
|
316
|
+
include: [...resolved(ruleset, "include_refs", RULESET_DEFAULTS)],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
bypass_actors: [...resolved(ruleset, "bypass_actors", RULESET_DEFAULTS)],
|
|
320
|
+
rules,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read a project's config without failing on its absence.
|
|
326
|
+
*
|
|
327
|
+
* A project with no `.lisa.config.json` still gets the default ruleset, which
|
|
328
|
+
* is what the deleted template gave it. Refusing here would turn "no config"
|
|
329
|
+
* into "no branch protection", a silent downgrade at exactly the moment
|
|
330
|
+
* governance is being applied.
|
|
331
|
+
* @param {string} projectPath The project directory.
|
|
332
|
+
* @returns {{gates: object, policy: object}} Resolved config.
|
|
333
|
+
*/
|
|
334
|
+
export function readProjectConfig(projectPath) {
|
|
335
|
+
const { gates, policy } = readGates(projectPath);
|
|
336
|
+
return { gates, policy };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* CLI entry point: print the payload as JSON.
|
|
341
|
+
*/
|
|
342
|
+
function main() {
|
|
343
|
+
const argv = process.argv.slice(2);
|
|
344
|
+
const flag = name => {
|
|
345
|
+
const hit = argv.find(arg => arg.startsWith(`--${name}=`));
|
|
346
|
+
return hit ? hit.slice(name.length + 3) : null;
|
|
347
|
+
};
|
|
348
|
+
const projectPath = flag("project") ?? process.cwd();
|
|
349
|
+
const { gates, policy } = readProjectConfig(projectPath);
|
|
350
|
+
process.stdout.write(
|
|
351
|
+
`${JSON.stringify(
|
|
352
|
+
buildRulesetPayload({
|
|
353
|
+
gates,
|
|
354
|
+
policy,
|
|
355
|
+
moment: flag("moment") ?? "pull-request",
|
|
356
|
+
}),
|
|
357
|
+
null,
|
|
358
|
+
2
|
|
359
|
+
)}\n`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Not `import.meta.url === process.argv[1]`. Node resolves the module URL
|
|
364
|
+
// through realpath while argv[1] keeps whatever spelling the caller typed, so
|
|
365
|
+
// on a symlinked path — a macOS temp dir, a git worktree — they differ, main()
|
|
366
|
+
// never runs, and this generator prints NOTHING and exits 0. Measured: the
|
|
367
|
+
// applier then read an empty template and skipped the only ruleset carrying
|
|
368
|
+
// branch protection, reporting success.
|
|
369
|
+
if (invokedAsScript(import.meta.url)) {
|
|
370
|
+
try {
|
|
371
|
+
main();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
console.error(err.message);
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
}
|
|
376
|
+
}
|