@homeflare/alchemy 0.13.0 → 0.15.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/README.md +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/openbao/index.d.ts +10 -3
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/backup-job.d.ts.map +1 -1
- package/dist/proxmox/constraint-guard.d.ts +28 -0
- package/dist/proxmox/constraint-guard.d.ts.map +1 -0
- package/dist/proxmox/constraints.d.ts +71 -0
- package/dist/proxmox/constraints.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +19 -0
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +6 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +816 -257
- package/dist/proxmox/index.js.map +33 -24
- package/dist/proxmox/notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
- package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
- package/dist/proxmox/pool.d.ts.map +1 -1
- package/dist/proxmox/provision-baseline.d.ts +30 -5
- package/dist/proxmox/provision-baseline.d.ts.map +1 -1
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource-spec.d.ts +120 -0
- package/dist/proxmox/resource-spec.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +10 -91
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/sdn-zone.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/docs/provision-baseline.md +34 -7
- package/docs/proxmox-lxc.md +40 -0
- package/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- package/src/openbao/index-types.test.ts +87 -0
- package/src/openbao/index.ts +15 -3
- package/src/proxmox/acl.ts +2 -0
- package/src/proxmox/backup-job.ts +2 -0
- package/src/proxmox/constraint-guard.ts +53 -0
- package/src/proxmox/constraints.test.ts +260 -0
- package/src/proxmox/constraints.ts +135 -0
- package/src/proxmox/firewall-alias.ts +5 -0
- package/src/proxmox/generated/constraints/index.ts +31 -0
- package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
- package/src/proxmox/generated/constraints/pve-access.ts +57 -0
- package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
- package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
- package/src/proxmox/group.ts +2 -0
- package/src/proxmox/ha-resource.ts +5 -0
- package/src/proxmox/ha-rule.ts +2 -0
- package/src/proxmox/index.ts +12 -0
- package/src/proxmox/lxc-interior.test.ts +61 -0
- package/src/proxmox/notification-matcher.ts +5 -0
- package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
- package/src/proxmox/pbs-datastore.ts +8 -1
- package/src/proxmox/pbs-notification-matcher.ts +5 -0
- package/src/proxmox/pbs-prune-job.ts +2 -0
- package/src/proxmox/pbs-sync-job-form.ts +2 -0
- package/src/proxmox/pbs-verify-job.ts +2 -0
- package/src/proxmox/pool.ts +2 -0
- package/src/proxmox/provision-baseline.test.ts +38 -0
- package/src/proxmox/provision-baseline.ts +73 -14
- package/src/proxmox/provision-bootstrap.test.ts +51 -0
- package/src/proxmox/replication-job.ts +5 -0
- package/src/proxmox/resource-spec.ts +117 -0
- package/src/proxmox/resource.ts +50 -91
- package/src/proxmox/role.ts +2 -0
- package/src/proxmox/sdn-vnet.ts +2 -0
- package/src/proxmox/sdn-zone.ts +2 -0
- package/src/proxmox/storage.ts +2 -0
- package/src/proxmox/user.ts +2 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# `declareRepoPolicy` — the ruleset half's hazards
|
|
2
|
+
|
|
3
|
+
Everything here is about `GitHub.Ruleset` in `alchemy@2.0.0-beta.79`, not about the
|
|
4
|
+
policy this kit declares. It is extracted from [repo-policy.md](./repo-policy.md) so the
|
|
5
|
+
reasoning stays whole rather than being shaved to fit a line cap.
|
|
6
|
+
|
|
7
|
+
⚠️ **`GitHub.Repository` shares none of it.** Its `reconcile` probes by name, converges
|
|
8
|
+
onto whatever is live, and `PATCH`es only the fields it holds. The two halves of the
|
|
9
|
+
helper fail differently, which is the whole reason this file exists.
|
|
10
|
+
|
|
11
|
+
## ⛔ The ruleset cannot be adopted — it duplicates
|
|
12
|
+
|
|
13
|
+
Read out of `alchemy@2.0.0-beta.79`'s own source on 2026-09-22
|
|
14
|
+
(`node_modules/alchemy/lib/github/Ruleset.js`):
|
|
15
|
+
|
|
16
|
+
- `read` returns `undefined` whenever there is no prior `output`. Alchemy's adoption
|
|
17
|
+
routes on what `read` reports, so with no state the engine plans a **create**.
|
|
18
|
+
- `reconcile` looks the live ruleset up **only by the id in that output**. With none, it
|
|
19
|
+
calls `createRepoRuleset` unconditionally — it never searches by name.
|
|
20
|
+
|
|
21
|
+
GitHub permits several rulesets with the same name on one repository, so nothing errors.
|
|
22
|
+
The repository quietly ends up with **two `main` rulesets, both enforcing**, and the
|
|
23
|
+
second one is the only one Alchemy will ever converge.
|
|
24
|
+
|
|
25
|
+
**Before the first deploy onto a repository that already has a ruleset:**
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
gh api repos/<owner>/<repo>/rulesets --jq '.[] | select(.target=="branch") | "\(.id) \(.name)"'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Delete the hand-made ruleset of the same name, or give this one a `rulesetName` of its
|
|
32
|
+
own. After the first deploy the state carries the id and every later deploy converges
|
|
33
|
+
that one ruleset.
|
|
34
|
+
|
|
35
|
+
★ **Or take the repository half only.** `repoPolicy` returns the two prop objects
|
|
36
|
+
separately, so a stack whose ruleset is already owned by something else can declare the
|
|
37
|
+
settings that adopt cleanly and leave the ruleset alone:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
yield * GitHub.Repository('api', repoPolicy({ owner, repository, checks }).repository);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
⚠️ **`GitHub.Repository` does not share the problem.** Its `reconcile` probes by name and
|
|
44
|
+
converges onto whatever is live, so the repository half adopts cleanly on the first
|
|
45
|
+
deploy even though the plan prints `create`. The two halves of this helper fail
|
|
46
|
+
differently, which is exactly why it is worth writing down.
|
|
47
|
+
|
|
48
|
+
⛔ **The ruleset half never plans a no-op — measured, not inferred.** Alchemy `deepEqual`s
|
|
49
|
+
the live ruleset against the parameters **it** builds, and it builds a strict subset of
|
|
50
|
+
what GitHub stores. Read live on 2026-09-22 from this repo's own ruleset, GitHub returns
|
|
51
|
+
`required_status_checks.parameters.do_not_enforce_on_create: false` and, on a
|
|
52
|
+
`pull_request` rule, `required_reviewers`, `allowed_merge_methods` and
|
|
53
|
+
`require_extra_approval_for_unattributed_changes` — none of which Alchemy sends. The
|
|
54
|
+
comparison is therefore unequal **forever**, and every deploy issues an idempotent
|
|
55
|
+
`updateRepoRuleset`. It is convergence noise, not drift: the write is the same payload
|
|
56
|
+
each time. But do not expect a clean plan, and do not read the write as a change.
|
|
57
|
+
|
|
58
|
+
⛔ **The ruleset's `rules` and `bypass_actors` are replaced wholesale, not merged.**
|
|
59
|
+
Alchemy sends the full desired list. Any rule type this helper does not express —
|
|
60
|
+
`creation`, `update`, `required_linear_history`, `required_signatures`,
|
|
61
|
+
`required_deployments`, tag and file-path rules — is **dropped** from a ruleset Alchemy
|
|
62
|
+
owns, as is any bypass actor added by hand. Because the ruleset cannot be adopted this
|
|
63
|
+
cannot wipe a pre-existing one on a first deploy; it bites on a ruleset Alchemy created
|
|
64
|
+
that someone later edited in the GitHub UI. The repository half does not behave this way:
|
|
65
|
+
Alchemy `PATCH`es only the fields it holds, so settings outside `settings` are untouched.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Repository policy
|
|
2
|
+
|
|
3
|
+
One call that declares a GitHub repository's merge policy and the ruleset that enforces
|
|
4
|
+
it: squash-only merges, auto-merge on, head branches deleted, and a ruleset over the
|
|
5
|
+
default branch that blocks deletion and force pushes and requires the checks you name.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { declareRepoPolicy } from '@homeflare/alchemy/github';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
// policy.repository → GitHub.Repository, policy.ruleset → GitHub.Ruleset
|
|
13
|
+
const policy = yield* declareRepoPolicy('api', {
|
|
14
|
+
owner: 'my-org',
|
|
15
|
+
repository: 'api',
|
|
16
|
+
checks: ['ci', 'secret scan'],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Provide `GitHub.providers()`. This subpath ships **no provider of its own** — everything
|
|
22
|
+
here composes Alchemy's first-class `GitHub.Repository` and `GitHub.Ruleset`.
|
|
23
|
+
|
|
24
|
+
`repoPolicy(options)` is the same thing without the resources: the two prop objects, pure,
|
|
25
|
+
for a test or for a stack that wants to declare them itself.
|
|
26
|
+
|
|
27
|
+
## What it declares
|
|
28
|
+
|
|
29
|
+
| where | property | value |
|
|
30
|
+
| ------------------- | ---------------------------------------- | --------------- |
|
|
31
|
+
| `GitHub.Repository` | `allowSquashMerge` | `true` |
|
|
32
|
+
| `GitHub.Repository` | `allowMergeCommit`, `allowRebaseMerge` | `false` |
|
|
33
|
+
| `GitHub.Repository` | `allowAutoMerge` | `autoMerge` |
|
|
34
|
+
| `GitHub.Repository` | `deleteBranchOnMerge` | `true` |
|
|
35
|
+
| `GitHub.Ruleset` | `conditions.include` | `include` |
|
|
36
|
+
| `GitHub.Ruleset` | `rules.deletion`, `rules.nonFastForward` | `true` |
|
|
37
|
+
| `GitHub.Ruleset` | `rules.requiredStatusChecks.checks` | `checks` |
|
|
38
|
+
| `GitHub.Ruleset` | `strictRequiredStatusChecksPolicy` | always `false` |
|
|
39
|
+
| `GitHub.Ruleset` | `bypassActors` | `[]` by default |
|
|
40
|
+
| `GitHub.Ruleset` | `rules.pullRequest` | only if asked |
|
|
41
|
+
|
|
42
|
+
Everything about the repository that is **not** merge policy — description, topics,
|
|
43
|
+
visibility, `hasWiki` — goes in `settings` and is merged underneath. ⛔ The policy fields
|
|
44
|
+
are spread after it, and `RepoPolicyRepositorySettings` omits them, so `settings` cannot
|
|
45
|
+
re-open a merge method through the back door.
|
|
46
|
+
|
|
47
|
+
⛔ **`baseUrl` is a top-level option, not a `settings` field.** GitHub Enterprise has to
|
|
48
|
+
apply to both resources or to neither; setting it on one would leave the repository on an
|
|
49
|
+
Enterprise host and its ruleset on github.com.
|
|
50
|
+
|
|
51
|
+
## ⛔ Auto-merge with nothing required merges immediately
|
|
52
|
+
|
|
53
|
+
`gh pr merge --auto` reads like a queue. It is one **only while something is
|
|
54
|
+
outstanding**. With no required status check and no required review pending, GitHub
|
|
55
|
+
merges the pull request on the spot — the flag does not wait for CI that was never
|
|
56
|
+
required to start.
|
|
57
|
+
|
|
58
|
+
So `repoPolicy` refuses `autoMerge` whenever the ruleset would not hold anything back.
|
|
59
|
+
⛔ **Five different inputs produce "nothing outstanding", and all five are refused:**
|
|
60
|
+
|
|
61
|
+
| input | why it merges anyway |
|
|
62
|
+
| ----------------------------------------- | ------------------------------------------------------------- |
|
|
63
|
+
| no `checks` and no `requiredApprovals` | nothing is pending, so `--auto` merges now |
|
|
64
|
+
| `enforcement: 'disabled'` / `'evaluate'` | the rules are listed and none of them block — the quiet door |
|
|
65
|
+
| `include: []` | the ruleset matches no ref; GitHub still shows it as `active` |
|
|
66
|
+
| a blank ref pattern, e.g. `include: ['']` | length is not coverage — matches no ref, past the guard |
|
|
67
|
+
| `exclude` cancelling every `include` | exclusions win; reads as a narrowing, acts as an off switch |
|
|
68
|
+
|
|
69
|
+
★ The last two were found by adversarial review after the first cut shipped. The guard
|
|
70
|
+
counted the array and spread `exclude` raw, so `include: [' ']` and
|
|
71
|
+
`include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` both produced an active
|
|
72
|
+
ruleset over nothing with `allowAutoMerge: true` — the same end state the empty-array
|
|
73
|
+
guard exists to prevent, through the door beside it.
|
|
74
|
+
|
|
75
|
+
⚠️ **Exact cancellation only.** Deciding in general whether a glob `exclude` swallows an
|
|
76
|
+
`include` means reimplementing GitHub's matcher and being quietly wrong about it. The
|
|
77
|
+
helper refuses the decidable case — every `include` pattern appearing verbatim in
|
|
78
|
+
`exclude` — and leaves `exclude: ['refs/heads/*']` to the operator.
|
|
79
|
+
|
|
80
|
+
⚠️ **`bypassActors` is not checked against `autoMerge`, and is `[]` by default.** An actor
|
|
81
|
+
you add can bypass the required checks; whether GitHub's auto-merge then lands the pull
|
|
82
|
+
request on red was not measured here. Add bypass actors deliberately.
|
|
83
|
+
|
|
84
|
+
⚠️ The test is "nothing outstanding", not "no checks" — a required review holds the pull
|
|
85
|
+
request open just as a required check does, so `requiredApprovals` with an empty `checks`
|
|
86
|
+
is allowed.
|
|
87
|
+
|
|
88
|
+
A repository that has no green run to require yet is a real state; say so explicitly:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
declareRepoPolicy('new-repo', { owner, repository, checks: [], autoMerge: false });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The branch is still protected — deletion and force pushes are blocked either way. Turn
|
|
95
|
+
`autoMerge` and `checks` on together once there is a green run to name.
|
|
96
|
+
|
|
97
|
+
## ⛔ Every context in `checks` must always report
|
|
98
|
+
|
|
99
|
+
A required context that never reports is not "passed", it is **pending**, and the pull
|
|
100
|
+
request waits on it forever — auto-merge included, which is the failure that looks like
|
|
101
|
+
the tool being broken.
|
|
102
|
+
|
|
103
|
+
Name an aggregate job that runs `if: always()` and fails when any upstream job did. Do
|
|
104
|
+
not name a job behind a `paths:` filter, a matrix leg, or anything a skipped workflow can
|
|
105
|
+
silence. Contexts are trimmed, de-duplicated and **sorted**, so writing the same two the
|
|
106
|
+
other way round is not a diff.
|
|
107
|
+
|
|
108
|
+
## ⛔ The ruleset half has hazards of its own
|
|
109
|
+
|
|
110
|
+
The vendor `Ruleset` **cannot be adopted** — a first deploy onto a repository that
|
|
111
|
+
already has a ruleset of that name creates a **second** one beside it, both enforcing —
|
|
112
|
+
it **never plans a no-op**, and it replaces its `rules` and `bypass_actors` **wholesale**.
|
|
113
|
+
None of that applies to the repository half.
|
|
114
|
+
|
|
115
|
+
⛔ Read [repo-policy-ruleset-hazards.md](./repo-policy-ruleset-hazards.md) before the
|
|
116
|
+
first deploy. It carries the preflight `gh api repos/<owner>/<repo>/rulesets` command and
|
|
117
|
+
the repository-half-only escape hatch.
|
|
118
|
+
|
|
119
|
+
## Required reviews are opt-in, and `0` is refused
|
|
120
|
+
|
|
121
|
+
Omit `requiredApprovals` and no `pull_request` rule is declared at all — the house
|
|
122
|
+
default, where a pull request is gated by its checks.
|
|
123
|
+
|
|
124
|
+
`requiredApprovals: 2` declares the rule, with stale reviews dismissed on push.
|
|
125
|
+
|
|
126
|
+
⛔ `requiredApprovals: 0` is **refused**, not treated as "no reviews". Zero approvals is
|
|
127
|
+
the solo-maintainer shape — every change through a pull request, no second person — and
|
|
128
|
+
it works only with `require_extra_approval_for_unattributed_changes: false`. Alchemy's
|
|
129
|
+
`Ruleset` has no property for that field, and GitHub defaults it to `true` when it is
|
|
130
|
+
omitted (measured 2026-09-17), which blocks the very pull requests the zero was meant to
|
|
131
|
+
let through. Accepting it would ship a rule that does the opposite of what it says.
|
|
132
|
+
|
|
133
|
+
## ★ `strict` is always off, and is not a parameter
|
|
134
|
+
|
|
135
|
+
"Require branches to be up to date before merging" means every merge to the default
|
|
136
|
+
branch invalidates every other open pull request. With auto-merge on, that is a re-run
|
|
137
|
+
storm that settles only when the queue empties. The checks still have to pass; they just
|
|
138
|
+
do not have to have passed against the very tip.
|
|
139
|
+
|
|
140
|
+
## ★ `~DEFAULT_BRANCH`, not `refs/heads/main`
|
|
141
|
+
|
|
142
|
+
`include` defaults to `['~DEFAULT_BRANCH']`, which GitHub resolves per repository. One
|
|
143
|
+
policy covers a repo whose default branch is `master` or `trunk`, and renaming the
|
|
144
|
+
default branch later does not leave the ruleset pointing at a branch that no longer
|
|
145
|
+
exists. Pass your own `include`/`exclude` for anything else.
|
|
146
|
+
|
|
147
|
+
## ★ Both resources retain
|
|
148
|
+
|
|
149
|
+
`retain` is already the vendor default for `Repository` and `Ruleset`. The helper pipes
|
|
150
|
+
it anyway, so that a vendor change cannot quietly turn "drop this call from the stack"
|
|
151
|
+
into "delete the repository" or "unprotect the default branch". Opt into deletion with
|
|
152
|
+
`RemovalPolicy.destroy()` at the call site if you really mean it.
|
|
153
|
+
|
|
154
|
+
`adopt: true` / `adopt: false` pipe Alchemy's adopt policy onto both resources; omitted,
|
|
155
|
+
the deploy's own policy decides. ⚠️ It changes nothing about the ruleset hazard above.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"types": "./dist/forgejo/index.d.ts",
|
|
37
37
|
"default": "./dist/forgejo/index.js"
|
|
38
38
|
},
|
|
39
|
+
"./github": {
|
|
40
|
+
"types": "./dist/github/index.d.ts",
|
|
41
|
+
"default": "./dist/github/index.js"
|
|
42
|
+
},
|
|
39
43
|
"./openbao": {
|
|
40
44
|
"types": "./dist/openbao/index.d.ts",
|
|
41
45
|
"default": "./dist/openbao/index.js"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub helpers for Alchemy — one repository's house policy in one call.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS SUBPATH SHIPS NO PROVIDER, AND DELIBERATELY SO. GitHub is first-class in
|
|
5
|
+
* Alchemy; everything here composes the vendor's own `Repository` and `Ruleset`.
|
|
6
|
+
* Provide `GitHub.providers()` exactly as you would without it.
|
|
7
|
+
* ★ WHY IT EXISTS ANYWAY. "Open a pull request, let auto-merge land it" is safe only when
|
|
8
|
+
* five repository properties and four ruleset rules all agree, and every way of getting
|
|
9
|
+
* one of them wrong is silent — an auto-merge that lands on red, a force push that
|
|
10
|
+
* rewrites the branch a release tagged, a required context nothing ever reports. One
|
|
11
|
+
* call puts the whole policy in one place, and one place is where it gets fixed.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ THIS BARREL IS THE PUBLIC API. `repo-policy-form.ts` is reachable by path if you
|
|
14
|
+
* genuinely need an internal — a deliberate act rather than an accident of barrelling.
|
|
15
|
+
*/
|
|
16
|
+
export {
|
|
17
|
+
type RepoPolicy,
|
|
18
|
+
type RepoPolicyBypassActor,
|
|
19
|
+
type RepoPolicyOptions,
|
|
20
|
+
type RepoPolicyRepositorySettings,
|
|
21
|
+
repoPolicy,
|
|
22
|
+
} from './repo-policy-form.ts';
|
|
23
|
+
export { type DeclareRepoPolicyOptions, declareRepoPolicy } from './repo-policy.ts';
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type RepoPolicyRepositorySettings, repoPolicy } from './repo-policy-form.ts';
|
|
3
|
+
|
|
4
|
+
const base = { checks: ['ci', 'secret scan'], owner: 'acme', repository: 'widget' } as const;
|
|
5
|
+
|
|
6
|
+
describe('repoPolicy — the repository half', () => {
|
|
7
|
+
test('squash only, auto-merge on, head branches deleted', () => {
|
|
8
|
+
const { repository } = repoPolicy(base);
|
|
9
|
+
|
|
10
|
+
expect(repository).toMatchObject({
|
|
11
|
+
allowAutoMerge: true,
|
|
12
|
+
allowMergeCommit: false,
|
|
13
|
+
allowRebaseMerge: false,
|
|
14
|
+
allowSquashMerge: true,
|
|
15
|
+
deleteBranchOnMerge: true,
|
|
16
|
+
name: 'widget',
|
|
17
|
+
owner: 'acme',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('caller settings pass through but cannot re-open a merge method', () => {
|
|
22
|
+
// ⛔ The policy fields are spread AFTER `settings` for exactly this reason. A caller
|
|
23
|
+
// who could turn merge commits back on here would defeat the helper silently.
|
|
24
|
+
// ★ `RepoPolicyRepositorySettings` already OMITS the merge keys, so this cast is the
|
|
25
|
+
// only way to write the call at all — the type is the first guard, this is the
|
|
26
|
+
// second, for a JavaScript consumer the compiler never sees.
|
|
27
|
+
const settings = { allowMergeCommit: true, description: 'a widget' } as unknown;
|
|
28
|
+
const { repository } = repoPolicy({
|
|
29
|
+
...base,
|
|
30
|
+
settings: settings as RepoPolicyRepositorySettings,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(repository.description).toBe('a widget');
|
|
34
|
+
expect(repository.allowMergeCommit).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('repoPolicy — the ruleset half', () => {
|
|
39
|
+
test('covers the default branch, blocks deletion and force pushes, strict off', () => {
|
|
40
|
+
const { ruleset } = repoPolicy(base);
|
|
41
|
+
|
|
42
|
+
expect(ruleset).toMatchObject({
|
|
43
|
+
enforcement: 'active',
|
|
44
|
+
name: 'main',
|
|
45
|
+
owner: 'acme',
|
|
46
|
+
repository: 'widget',
|
|
47
|
+
target: 'branch',
|
|
48
|
+
});
|
|
49
|
+
expect(ruleset.conditions).toEqual({ exclude: [], include: ['~DEFAULT_BRANCH'] });
|
|
50
|
+
expect(ruleset.rules?.deletion).toBe(true);
|
|
51
|
+
expect(ruleset.rules?.nonFastForward).toBe(true);
|
|
52
|
+
expect(ruleset.rules?.requiredStatusChecks?.strictRequiredStatusChecksPolicy).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('nobody bypasses unless the caller names an actor', () => {
|
|
56
|
+
expect(repoPolicy(base).ruleset.bypassActors).toEqual([]);
|
|
57
|
+
expect(
|
|
58
|
+
repoPolicy({ ...base, bypassActors: [{ actorId: 5, actorType: 'RepositoryRole' }] }).ruleset
|
|
59
|
+
.bypassActors,
|
|
60
|
+
).toEqual([{ actorId: 5, actorType: 'RepositoryRole' }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('checks are trimmed, de-duplicated and sorted, so re-ordering is not a diff', () => {
|
|
64
|
+
// ⚠️ THE NO-OP PROPERTY. GitHub stores the contexts in the order they were sent, so
|
|
65
|
+
// an unsorted list would diff against itself the first time someone wrote the same
|
|
66
|
+
// two contexts the other way round — a deploy that changes nothing and says it did.
|
|
67
|
+
const one = repoPolicy({ ...base, checks: ['ci', 'secret scan'] });
|
|
68
|
+
const other = repoPolicy({ ...base, checks: [' secret scan ', 'ci', 'ci'] });
|
|
69
|
+
|
|
70
|
+
expect(other).toEqual(one);
|
|
71
|
+
expect(one.ruleset.rules?.requiredStatusChecks?.checks).toEqual([
|
|
72
|
+
{ context: 'ci' },
|
|
73
|
+
{ context: 'secret scan' },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the ruleset name and the refs it covers are the callers', () => {
|
|
78
|
+
const { ruleset } = repoPolicy({
|
|
79
|
+
...base,
|
|
80
|
+
exclude: ['refs/heads/dev/*'],
|
|
81
|
+
include: ['refs/heads/release/*'],
|
|
82
|
+
rulesetName: 'release',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(ruleset.name).toBe('release');
|
|
86
|
+
expect(ruleset.conditions).toEqual({
|
|
87
|
+
exclude: ['refs/heads/dev/*'],
|
|
88
|
+
include: ['refs/heads/release/*'],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('repoPolicy — required reviews are opt-in', () => {
|
|
94
|
+
test('no pull_request rule unless approvals were asked for', () => {
|
|
95
|
+
expect(repoPolicy(base).ruleset.rules?.pullRequest).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('asking for approvals declares the rule and dismisses stale reviews', () => {
|
|
99
|
+
expect(repoPolicy({ ...base, requiredApprovals: 2 }).ruleset.rules?.pullRequest).toEqual({
|
|
100
|
+
dismissStaleReviewsOnPush: true,
|
|
101
|
+
requiredApprovingReviewCount: 2,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('zero approvals is refused, naming the flag Alchemy cannot send', () => {
|
|
106
|
+
// ⛔ Not pedantry: 0 is the solo-maintainer shape, and it needs a field the vendor
|
|
107
|
+
// resource has no property for. Accepting it would ship a rule that blocks the
|
|
108
|
+
// pull requests it was written to allow.
|
|
109
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: 0 })).toThrow(
|
|
110
|
+
/require_extra_approval_for_unattributed_changes/,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a fractional or negative count is refused too', () => {
|
|
115
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: 1.5 })).toThrow(/whole number/);
|
|
116
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: -1 })).toThrow(/whole number/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('repoPolicy — the auto-merge trap', () => {
|
|
121
|
+
test('auto-merge with nothing required at all is refused', () => {
|
|
122
|
+
// ⛔ THE TRAP THIS HELPER EXISTS FOR. `gh pr merge --auto` is a queue only while
|
|
123
|
+
// something is outstanding; with nothing required GitHub merges on the spot.
|
|
124
|
+
expect(() => repoPolicy({ ...base, checks: [] })).toThrow(/immediately/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a required review is something outstanding, so an empty checks list is fine', () => {
|
|
128
|
+
// ⚠️ The guard is not "no checks": auto-merge waits on a required approval too, and
|
|
129
|
+
// refusing that combination would block a legitimate review-gated repository.
|
|
130
|
+
const { repository, ruleset } = repoPolicy({ ...base, checks: [], requiredApprovals: 1 });
|
|
131
|
+
|
|
132
|
+
expect(repository.allowAutoMerge).toBe(true);
|
|
133
|
+
expect(ruleset.rules?.requiredStatusChecks).toBeUndefined();
|
|
134
|
+
expect(ruleset.rules?.pullRequest?.requiredApprovingReviewCount).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('turning auto-merge off is the only way to declare an empty checks list', () => {
|
|
138
|
+
const { repository, ruleset } = repoPolicy({ ...base, autoMerge: false, checks: [] });
|
|
139
|
+
|
|
140
|
+
expect(repository.allowAutoMerge).toBe(false);
|
|
141
|
+
expect(ruleset.rules?.requiredStatusChecks).toBeUndefined();
|
|
142
|
+
// The branch is still protected — that is the point of the bootstrap state.
|
|
143
|
+
expect(ruleset.rules?.deletion).toBe(true);
|
|
144
|
+
expect(ruleset.rules?.nonFastForward).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a blank context is refused rather than stored as a check nothing reports', () => {
|
|
148
|
+
expect(() => repoPolicy({ ...base, checks: ['ci', ' '] })).toThrow(/blank/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('auto-merge over a ruleset that is not enforcing is refused', () => {
|
|
152
|
+
// ⚠️ THE QUIET DOOR. The checks are listed on the ruleset and none of them block
|
|
153
|
+
// anything, so auto-merge lands the pull request while the plan looks correct.
|
|
154
|
+
expect(() => repoPolicy({ ...base, enforcement: 'evaluate' })).toThrow(/enforcement/);
|
|
155
|
+
expect(() => repoPolicy({ ...base, enforcement: 'disabled' })).toThrow(/immediately/);
|
|
156
|
+
expect(
|
|
157
|
+
repoPolicy({ ...base, autoMerge: false, enforcement: 'evaluate' }).ruleset.enforcement,
|
|
158
|
+
).toBe('evaluate');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('an explicitly empty include is refused — it would match no ref at all', () => {
|
|
162
|
+
// ⚠️ THE QUIETEST DOOR. GitHub still shows the ruleset as active; it just covers
|
|
163
|
+
// nothing, so every rule on it is decorative.
|
|
164
|
+
expect(() => repoPolicy({ ...base, include: [] })).toThrow(/matches no ref/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a blank ref pattern is refused — length is not coverage', () => {
|
|
168
|
+
// ⚠️ THE DOOR BESIDE THE GUARD. `[' ']` has length 1, so a guard that counts the
|
|
169
|
+
// array passes it; GitHub then stores a condition that matches nothing. Identical
|
|
170
|
+
// end state to `include: []`, reached past the check that exists to stop it.
|
|
171
|
+
expect(() => repoPolicy({ ...base, include: [' '] })).toThrow(/blank/);
|
|
172
|
+
expect(() => repoPolicy({ ...base, exclude: [''] })).toThrow(/blank/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('an exclude that cancels every include is refused', () => {
|
|
176
|
+
// ⚠️ THE DOOR THAT READS AS A NARROWING. Exclusions win in a GitHub ruleset, so this
|
|
177
|
+
// is a ruleset over nothing — but it looks like scoping, not like switching off.
|
|
178
|
+
expect(() => repoPolicy({ ...base, exclude: ['~DEFAULT_BRANCH'] })).toThrow(
|
|
179
|
+
/cancels every include/,
|
|
180
|
+
);
|
|
181
|
+
expect(() =>
|
|
182
|
+
repoPolicy({
|
|
183
|
+
...base,
|
|
184
|
+
exclude: ['refs/heads/release/*', 'refs/heads/main'],
|
|
185
|
+
include: ['refs/heads/main', 'refs/heads/release/*'],
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(/cancels every include/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('an exclude that leaves an include standing is a narrowing, and allowed', () => {
|
|
191
|
+
// ★ EXACT CANCELLATION ONLY. Deciding glob overlap in general means reimplementing
|
|
192
|
+
// GitHub's matcher; a narrow guard that is always right beats a broad one that
|
|
193
|
+
// false-refuses a legitimate scope.
|
|
194
|
+
const { ruleset } = repoPolicy({
|
|
195
|
+
...base,
|
|
196
|
+
exclude: ['refs/heads/release/*'],
|
|
197
|
+
include: ['~DEFAULT_BRANCH', 'refs/heads/release/*'],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ★ Sorted, so `refs/…` precedes the `~` tokens — the ordering is normalization, not
|
|
201
|
+
// a preference, and it is what keeps a second deploy from diffing against itself.
|
|
202
|
+
expect(ruleset.conditions).toEqual({
|
|
203
|
+
exclude: ['refs/heads/release/*'],
|
|
204
|
+
include: ['refs/heads/release/*', '~DEFAULT_BRANCH'],
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('ref patterns are trimmed, de-duplicated and sorted, so re-ordering is not a diff', () => {
|
|
209
|
+
const one = repoPolicy({ ...base, include: ['refs/heads/main', 'refs/heads/release/*'] });
|
|
210
|
+
const other = repoPolicy({
|
|
211
|
+
...base,
|
|
212
|
+
include: [' refs/heads/release/* ', 'refs/heads/main', 'refs/heads/main'],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(other).toEqual(one);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('repoPolicy — one host for both halves', () => {
|
|
220
|
+
test('baseUrl reaches the repository and the ruleset, or neither', () => {
|
|
221
|
+
// ⛔ Setting it on one would point the repository at an Enterprise host and its
|
|
222
|
+
// ruleset at github.com — two halves of one policy on two different instances.
|
|
223
|
+
const { repository, ruleset } = repoPolicy({ ...base, baseUrl: 'github.example.com' });
|
|
224
|
+
|
|
225
|
+
expect(repository.baseUrl).toBe('github.example.com');
|
|
226
|
+
expect(ruleset.baseUrl).toBe('github.example.com');
|
|
227
|
+
|
|
228
|
+
const plain = repoPolicy(base);
|
|
229
|
+
expect('baseUrl' in plain.repository).toBe(false);
|
|
230
|
+
expect('baseUrl' in plain.ruleset).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('repoPolicy — required inputs', () => {
|
|
235
|
+
test('an empty owner or repository is refused', () => {
|
|
236
|
+
expect(() => repoPolicy({ ...base, owner: ' ' })).toThrow(/owner is required/);
|
|
237
|
+
expect(() => repoPolicy({ ...base, repository: '' })).toThrow(/repository is required/);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `repoPolicy` — one repository's house merge policy, as plain Alchemy props.
|
|
3
|
+
*
|
|
4
|
+
* ★ PURE ON PURPOSE, AND TYPE-ONLY IMPORTS. The exact shape a stack will deploy can be
|
|
5
|
+
* asserted in a unit test with no credentials, no network and no Effect runtime.
|
|
6
|
+
* `repo-policy.ts` is the thin half that turns these props into resources, and
|
|
7
|
+
* `repo-policy-guards.ts` holds everything this refuses and why.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ AUTO-MERGE WITH NOTHING TO WAIT FOR MERGES IMMEDIATELY. `gh pr merge --auto` is a
|
|
10
|
+
* queue only while something is outstanding. Five different inputs produce "nothing
|
|
11
|
+
* outstanding" — no `checks` and no `requiredApprovals`, an `enforcement` that is not
|
|
12
|
+
* `active`, and three ways to land a ruleset that matches no ref (an empty `include`, a
|
|
13
|
+
* blank ref pattern, an `exclude` that cancels every `include`). All five are refused.
|
|
14
|
+
* See the guards module.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ EVERY CONTEXT IN `checks` MUST ALWAYS REPORT. A required context that never reports
|
|
17
|
+
* is not "passed", it is "pending", and the pull request waits on it forever —
|
|
18
|
+
* auto-merge included. Point this at an aggregate job that runs `if: always()` and
|
|
19
|
+
* fails when any upstream job did, never at a job behind a `paths:` filter, a matrix
|
|
20
|
+
* leg, or anything a skipped workflow can silence.
|
|
21
|
+
*/
|
|
22
|
+
import type { RepositoryProps, RulesetProps } from 'alchemy/GitHub';
|
|
23
|
+
import {
|
|
24
|
+
assertAutoMergeWaits,
|
|
25
|
+
normalizeChecks,
|
|
26
|
+
normalizeExclude,
|
|
27
|
+
normalizeInclude,
|
|
28
|
+
requireName,
|
|
29
|
+
resolveApprovals,
|
|
30
|
+
} from './repo-policy-guards.ts';
|
|
31
|
+
|
|
32
|
+
/** Alchemy's own bypass-actor shape, re-exported so a caller imports one module, not two. */
|
|
33
|
+
export type RepoPolicyBypassActor = NonNullable<RulesetProps['bypassActors']>[number];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Repository fields the policy does NOT own — description, topics, visibility, …
|
|
37
|
+
* ⛔ Merged UNDERNEATH the policy, so a caller can describe their repo but cannot quietly
|
|
38
|
+
* re-enable merge commits through this door. The omitted keys are the policy itself.
|
|
39
|
+
* ⛔ `baseUrl` IS OMITTED TOO, and is a top-level option instead. Setting it on only one
|
|
40
|
+
* of the two resources would point the repository at a GitHub Enterprise host and its
|
|
41
|
+
* ruleset at github.com — two halves of one policy on two different instances.
|
|
42
|
+
*/
|
|
43
|
+
export type RepoPolicyRepositorySettings = Omit<
|
|
44
|
+
RepositoryProps,
|
|
45
|
+
| 'allowAutoMerge'
|
|
46
|
+
| 'allowMergeCommit'
|
|
47
|
+
| 'allowRebaseMerge'
|
|
48
|
+
| 'allowSquashMerge'
|
|
49
|
+
| 'baseUrl'
|
|
50
|
+
| 'deleteBranchOnMerge'
|
|
51
|
+
| 'name'
|
|
52
|
+
| 'owner'
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
export interface RepoPolicyOptions {
|
|
56
|
+
/** Repository owner — a user or organization login. */
|
|
57
|
+
readonly owner: string;
|
|
58
|
+
/** Repository name. */
|
|
59
|
+
readonly repository: string;
|
|
60
|
+
/**
|
|
61
|
+
* Status-check contexts a pull request must pass, named exactly as the job reports
|
|
62
|
+
* them. Trimmed, de-duplicated and sorted, so re-ordering the list is not a diff.
|
|
63
|
+
*/
|
|
64
|
+
readonly checks: readonly string[];
|
|
65
|
+
/** @default true — with nothing required it is refused, not quietly downgraded. */
|
|
66
|
+
readonly autoMerge?: boolean;
|
|
67
|
+
/** @default 'main' */
|
|
68
|
+
readonly rulesetName?: string;
|
|
69
|
+
/** Ref patterns the ruleset covers. @default ['~DEFAULT_BRANCH'] */
|
|
70
|
+
readonly include?: readonly string[];
|
|
71
|
+
/**
|
|
72
|
+
* Ref patterns exempted from it. @default []
|
|
73
|
+
* ⛔ Exclusions win over inclusions, so one that cancels every `include` is refused —
|
|
74
|
+
* it reads as a narrowing and acts as an off switch.
|
|
75
|
+
*/
|
|
76
|
+
readonly exclude?: readonly string[];
|
|
77
|
+
/** Who may bypass the ruleset. @default [] — nobody, administrators included. */
|
|
78
|
+
readonly bypassActors?: readonly RepoPolicyBypassActor[];
|
|
79
|
+
/**
|
|
80
|
+
* Approving reviews a pull request needs. Omit for none — `0` is refused rather than
|
|
81
|
+
* read as "no reviews"; `resolveApprovals` in the guards module says why.
|
|
82
|
+
*/
|
|
83
|
+
readonly requiredApprovals?: number;
|
|
84
|
+
/** @default 'active' — anything else is refused alongside `autoMerge`. */
|
|
85
|
+
readonly enforcement?: 'active' | 'disabled' | 'evaluate';
|
|
86
|
+
/** GitHub Enterprise host, applied to BOTH resources or to neither. */
|
|
87
|
+
readonly baseUrl?: string;
|
|
88
|
+
/** Everything about the repository that is not merge policy. */
|
|
89
|
+
readonly settings?: RepoPolicyRepositorySettings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RepoPolicy {
|
|
93
|
+
readonly repository: RepositoryProps;
|
|
94
|
+
readonly ruleset: RulesetProps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_RULESET_NAME = 'main';
|
|
98
|
+
/**
|
|
99
|
+
* ★ `~DEFAULT_BRANCH`, NOT `refs/heads/main`. GitHub resolves it per repository, so one
|
|
100
|
+
* policy covers a repo whose default branch is `master` or `trunk`, and renaming the
|
|
101
|
+
* default branch later does not silently leave the ruleset pointing at nothing.
|
|
102
|
+
*/
|
|
103
|
+
const DEFAULT_INCLUDE: readonly string[] = ['~DEFAULT_BRANCH'];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The house policy for one repository: squash-only merges, auto-merge on, head branches
|
|
107
|
+
* deleted, and a ruleset over the default branch that blocks deletion and force pushes
|
|
108
|
+
* and requires `checks`.
|
|
109
|
+
*
|
|
110
|
+
* ★ `strictRequiredStatusChecksPolicy` IS ALWAYS FALSE, AND NOT A PARAMETER. "Require
|
|
111
|
+
* branches to be up to date" means every merge to the default branch invalidates every
|
|
112
|
+
* other open pull request, which with auto-merge on is a re-run storm that converges
|
|
113
|
+
* only when the queue empties. The checks still have to pass; they just do not have to
|
|
114
|
+
* have passed against the very tip.
|
|
115
|
+
*/
|
|
116
|
+
export function repoPolicy(options: RepoPolicyOptions): RepoPolicy {
|
|
117
|
+
const owner = requireName('owner', options.owner);
|
|
118
|
+
const repository = requireName('repository', options.repository);
|
|
119
|
+
const checks = normalizeChecks(options.checks);
|
|
120
|
+
const include = normalizeInclude(options.include, DEFAULT_INCLUDE);
|
|
121
|
+
const exclude = normalizeExclude(options.exclude, include);
|
|
122
|
+
const autoMerge = options.autoMerge ?? true;
|
|
123
|
+
const enforcement = options.enforcement ?? 'active';
|
|
124
|
+
const approvals = resolveApprovals(options.requiredApprovals);
|
|
125
|
+
assertAutoMergeWaits({ approvals, autoMerge, checkCount: checks.length, enforcement });
|
|
126
|
+
const host = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl };
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
repository: {
|
|
130
|
+
...options.settings,
|
|
131
|
+
...host,
|
|
132
|
+
// ⛔ Squash only. The ruleset's own `allowed_merge_methods` is not reachable through
|
|
133
|
+
// Alchemy's `pullRequest` rule, so the repository is the only place this holds.
|
|
134
|
+
allowAutoMerge: autoMerge,
|
|
135
|
+
allowMergeCommit: false,
|
|
136
|
+
allowRebaseMerge: false,
|
|
137
|
+
allowSquashMerge: true,
|
|
138
|
+
deleteBranchOnMerge: true,
|
|
139
|
+
name: repository,
|
|
140
|
+
owner,
|
|
141
|
+
},
|
|
142
|
+
ruleset: {
|
|
143
|
+
...host,
|
|
144
|
+
bypassActors: [...(options.bypassActors ?? [])],
|
|
145
|
+
conditions: { exclude, include },
|
|
146
|
+
enforcement,
|
|
147
|
+
name: options.rulesetName ?? DEFAULT_RULESET_NAME,
|
|
148
|
+
owner,
|
|
149
|
+
repository,
|
|
150
|
+
rules: {
|
|
151
|
+
deletion: true,
|
|
152
|
+
nonFastForward: true,
|
|
153
|
+
...(approvals === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: {
|
|
156
|
+
pullRequest: {
|
|
157
|
+
dismissStaleReviewsOnPush: true,
|
|
158
|
+
requiredApprovingReviewCount: approvals,
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
...(checks.length === 0
|
|
162
|
+
? {}
|
|
163
|
+
: {
|
|
164
|
+
requiredStatusChecks: {
|
|
165
|
+
checks: checks.map((context) => ({ context })),
|
|
166
|
+
strictRequiredStatusChecksPolicy: false,
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
target: 'branch',
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|