@homeflare/alchemy 0.25.1 → 0.27.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/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- package/dist/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1134 -2
- package/dist/linux/index.js.map +22 -5
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +2 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +3 -3
- package/docs/linux-sudo.md +187 -0
- package/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-form.ts +2 -1
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# `GitHub.RepositoryRuleset`
|
|
2
|
+
|
|
3
|
+
A bridge resource for the one thing upstream `GitHub.Ruleset` cannot do: find a ruleset
|
|
4
|
+
that already exists, by name, before creating a second one with the same name. See
|
|
5
|
+
[repository-ruleset.ts](../src/github/repository-ruleset.ts) for the full hazard writeup
|
|
6
|
+
and [repo-policy-ruleset-hazards.md](./repo-policy-ruleset-hazards.md) for the upstream
|
|
7
|
+
behavior this closes.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { RepositoryRuleset, RepositoryRulesetProvider } from '@homeflare/alchemy/github';
|
|
11
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
yield* RepositoryRuleset('main-ruleset', {
|
|
16
|
+
owner: 'taslabs-net',
|
|
17
|
+
repository: 'widgets',
|
|
18
|
+
name: 'main',
|
|
19
|
+
rules: {
|
|
20
|
+
deletion: true,
|
|
21
|
+
nonFastForward: true,
|
|
22
|
+
pullRequest: {
|
|
23
|
+
requiredApprovingReviewCount: 0,
|
|
24
|
+
allowedMergeMethods: ['squash'],
|
|
25
|
+
extraApprovalForUnattributedChanges: false,
|
|
26
|
+
},
|
|
27
|
+
requiredStatusChecks: { checks: [{ context: 'ci' }, { context: 'secret scan' }] },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Provide **both** `GitHub.providers()` (for `GitHub.Repository`, if declared alongside) and
|
|
34
|
+
`RepositoryRulesetProvider()` — this is a separate `Provider`, registered under its own
|
|
35
|
+
type string (`GitHub.RepositoryRuleset`), not inside upstream's `GitHub.Providers`
|
|
36
|
+
collection. The two coexist in one stack without any change to upstream's own code.
|
|
37
|
+
|
|
38
|
+
## Type string: H14
|
|
39
|
+
|
|
40
|
+
`GitHub.RepositoryRuleset` sits inside upstream's own `GitHub` namespace — the standard's
|
|
41
|
+
gap H14, recorded rather than avoided. The alternative was `HomeFlare.GitHub.Ruleset`;
|
|
42
|
+
`GitHub.RepositoryRuleset` was chosen because it is GitHub's own term for the object
|
|
43
|
+
(`repository-ruleset` in the OpenAPI schema) and reads naturally beside `GitHub.Ruleset`.
|
|
44
|
+
|
|
45
|
+
## Client: Octokit, not distilled — H15
|
|
46
|
+
|
|
47
|
+
`@distilled.cloud/github@1.0.0-rc.12` has `createRepoRuleset`/`updateRepoRuleset`, but its
|
|
48
|
+
request bodies are `effect/Schema` `S.Struct`s with no field for
|
|
49
|
+
`require_extra_approval_for_unattributed_changes` — the flag this whole resource exists
|
|
50
|
+
for (see below). Octokit's request builder was MEASURED
|
|
51
|
+
(`repository-ruleset-wire.test.ts`, a real `@octokit/rest` instance with a fetch shim, no
|
|
52
|
+
network) to forward that untyped key onto the wire unchanged. The client itself is not
|
|
53
|
+
hand-rolled: it is built from `GitHubCredentials`/`creds.octokit()`, upstream's own
|
|
54
|
+
exported credential service.
|
|
55
|
+
|
|
56
|
+
## What it models beyond upstream `RulesetProps`
|
|
57
|
+
|
|
58
|
+
| field | upstream `Ruleset` | this resource |
|
|
59
|
+
| ------------------------------------------------- | ------------------ | ------------------ |
|
|
60
|
+
| `pullRequest.allowedMergeMethods` | no | yes |
|
|
61
|
+
| `pullRequest.requiredReviewers` | no | yes |
|
|
62
|
+
| `pullRequest.extraApprovalForUnattributedChanges` | no | `false` only (H15) |
|
|
63
|
+
| `requiredStatusChecks.doNotEnforceOnCreate` | no | yes |
|
|
64
|
+
|
|
65
|
+
`RULE_TYPE_COVERAGE` in `repository-ruleset-constraints.ts` classifies every rule type
|
|
66
|
+
Octokit's own installed types know (21 of them — see the file for the 27.0.0-vs-29.0.1
|
|
67
|
+
version note) as `'modeled'` or `'refused'`; a live rule of a refused type, or a modeled
|
|
68
|
+
`pull_request` the declaration says nothing about, fails the plan by name rather than
|
|
69
|
+
being silently dropped by the wholesale `rules` PUT.
|
|
70
|
+
|
|
71
|
+
## What it refuses, and never does
|
|
72
|
+
|
|
73
|
+
- **A second same-named ruleset**: `DuplicateRuleset`, before reading either one.
|
|
74
|
+
- **Widening `bypassActors`**: refused on update; `undefined` means unmanaged (the live
|
|
75
|
+
value is carried forward unchanged), not "clear it".
|
|
76
|
+
- **A live rule of an unmodeled type**, or a live rule of ANY modeled type the
|
|
77
|
+
declaration omits by silence rather than by explicitly saying `false`: refused, naming
|
|
78
|
+
the field. This covers every rule, not only `pull_request`/`requiredStatusChecks` —
|
|
79
|
+
see `undeclaredLiveRuleRefusal`'s own comment for the concrete scenario (forgetting
|
|
80
|
+
`deletion: true` would otherwise silently drop branch-deletion protection) that made
|
|
81
|
+
this the general case rather than a special one.
|
|
82
|
+
- **Adding a required check that has never reported success** on the default branch's
|
|
83
|
+
current tip (checks + statuses): refused before any write. Documented limitation: this
|
|
84
|
+
checks the current tip only, not full history (repository-ruleset-octokit.ts).
|
|
85
|
+
- **A readback mismatch**: after any write, an INDEPENDENT `GET` — never the write's own
|
|
86
|
+
response — is compared against what was sent for the approval count, merge methods and
|
|
87
|
+
the extension flag. A mismatch fails the plan.
|
|
88
|
+
|
|
89
|
+
## Never-noop, fixed
|
|
90
|
+
|
|
91
|
+
Upstream `Ruleset#reconcile` compares its desired object against the live GET with a raw
|
|
92
|
+
`deepEqual` — no normalization. GitHub does not promise to echo `rules`, checks or merge
|
|
93
|
+
methods back in request order, and a live check carries `integration_id: null` where the
|
|
94
|
+
request never sent the key. `canonicalizeWireRuleset` (repository-ruleset-form.ts) sorts
|
|
95
|
+
every list with no meaningful order (recursively, key order included — see its own
|
|
96
|
+
comment for the bug that finding produced) before two wire rulesets are ever compared, so
|
|
97
|
+
a repository whose live ruleset already matches the declaration makes **zero** API calls.
|
|
98
|
+
|
|
99
|
+
## Removal policy
|
|
100
|
+
|
|
101
|
+
`retain`, always — deleting a ruleset takes an explicit `RemovalPolicy.destroy()` from the
|
|
102
|
+
caller, the same as upstream `GitHub.Ruleset`.
|
|
103
|
+
|
|
104
|
+
## Tests
|
|
105
|
+
|
|
106
|
+
- `repository-ruleset-lifecycle.test.ts` — a GET-only loopback fake serving the 16 live
|
|
107
|
+
rulesets this family was walked against; the house baseline's real drift against every
|
|
108
|
+
one, and the canonicalization/mutation checks.
|
|
109
|
+
- `repository-ruleset-write.test.ts` / `-write-refusals.test.ts` — a write-recording fake:
|
|
110
|
+
what a create sends, zero writes on a noop or a refusal, exactly one update on real
|
|
111
|
+
drift, and the readback check.
|
|
112
|
+
- `repository-ruleset-wire.test.ts` — the H15 measurement against a real Octokit instance.
|
|
113
|
+
- `repository-ruleset-coverage.test.ts` — the rule-type coverage change detector (the
|
|
114
|
+
completeness guarantee itself is enforced by `tsc`, not this file — see its header).
|
|
@@ -94,11 +94,22 @@ then tidiness.
|
|
|
94
94
|
|
|
95
95
|
## Conforms
|
|
96
96
|
|
|
97
|
+
- **`github/RepositoryRuleset`** (added 2026-09-23) closes the hazard the row above names:
|
|
98
|
+
probes by name (`read`/`reconcile` both), answers `Unowned` on a cold name match (H1),
|
|
99
|
+
normalizes before comparing so a matching live ruleset is a true noop, and refuses
|
|
100
|
+
(rather than silently drops) a live rule type it does not model. Two recorded
|
|
101
|
+
divergences: the type string sits in upstream's own `GitHub` namespace (H14), and the
|
|
102
|
+
client is Octokit rather than distilled because distilled's `S.Struct` request schemas
|
|
103
|
+
have no field for `require_extra_approval_for_unattributed_changes` (H15) — see
|
|
104
|
+
[repository-ruleset.md](./repository-ruleset.md). `declareRepoPolicy` is rewired onto
|
|
105
|
+
it; the row below is now describing upstream `GitHub.Ruleset` itself, which this family
|
|
106
|
+
does not touch and remains usable directly.
|
|
97
107
|
- **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
|
|
98
108
|
`GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
|
|
99
109
|
without prior output, so a first deploy creates a duplicate
|
|
100
110
|
([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
|
|
101
|
-
`Unowned`.
|
|
111
|
+
`Unowned`. Since 2026-09-23 `declareRepoPolicy` no longer takes this path (see the
|
|
112
|
+
`RepositoryRuleset` row above); this remains accurate for upstream `Ruleset` on its own.
|
|
102
113
|
- **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
|
|
103
114
|
`Website.Vite`.
|
|
104
115
|
- **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Draft: two upstream PRs to `alchemy-run/alchemy`
|
|
2
|
+
|
|
3
|
+
**Not posted.** Posting either is Tim's decision (alchemy-provider-standard's boundary:
|
|
4
|
+
"posting anything to an upstream repository is Tim's decision. Ask first, with options
|
|
5
|
+
and a recommendation."). This is the recommendation, drafted 2026-09-23 alongside
|
|
6
|
+
`GitHub.RepositoryRuleset`, the house's stopgap for PR 1 below.
|
|
7
|
+
|
|
8
|
+
## PR 1: `GitHub.Ruleset` — adopt by name, normalize before comparing
|
|
9
|
+
|
|
10
|
+
**Target**: `packages/alchemy/src/GitHub/Ruleset.ts`, pinned tag `v2.0.0-beta.79`.
|
|
11
|
+
|
|
12
|
+
**Problem** (measured from the source, see `repository-ruleset.ts`'s header):
|
|
13
|
+
`reconcile`'s `observed` lookup is `output === undefined ? undefined :
|
|
14
|
+
getRuleset(news, output.rulesetId)` — with no prior state it never searches by name, so a
|
|
15
|
+
first deploy onto a repository that already has a same-named ruleset creates a **second**
|
|
16
|
+
one. Separately, the `deepEqual` compare in the same function has no normalization: rule
|
|
17
|
+
order, `allowed_merge_methods` order, check order, and `integration_id: null` vs an
|
|
18
|
+
omitted key all make a matching live ruleset compare unequal, so a converged stack issues
|
|
19
|
+
an idempotent `updateRepoRuleset` on every single plan.
|
|
20
|
+
|
|
21
|
+
**Proposed fix**, following the shape `RepositoryRuleset` already proves out:
|
|
22
|
+
|
|
23
|
+
1. `read`, with `output === undefined`: list the repo's rulesets
|
|
24
|
+
(`octokit.paginate(getRepoRulesets, { includes_parents: false })`), filter by
|
|
25
|
+
`name`/`target`. Zero matches → `undefined`. One → `GET` by id, wrap `Unowned(attrs)`
|
|
26
|
+
(H1: identical is not automatically ours). More than one → a typed failure naming
|
|
27
|
+
every id, before reading any of them — `alchemy`'s `Data.TaggedError` convention.
|
|
28
|
+
2. `reconcile`'s observe step: the same name-probe when `output` is absent.
|
|
29
|
+
3. Normalize both sides of the `deepEqual` (or switch to Alchemy's own `deepEqual` plus a
|
|
30
|
+
canonicalization pass) before comparing: sort `rules` by `type`, sort
|
|
31
|
+
`allowed_merge_methods`, sort `required_status_checks` by context, and drop
|
|
32
|
+
`integration_id: null` before comparing.
|
|
33
|
+
|
|
34
|
+
**Compatibility**: additive. A caller with no prior state today gets a `create`; after
|
|
35
|
+
the fix, a repository with an existing same-named ruleset gets an adopt instead — a
|
|
36
|
+
behavior change, but the current behavior (silently making a duplicate) is the bug.
|
|
37
|
+
|
|
38
|
+
**House stopgap**: `GitHub.RepositoryRuleset` in this package, retained until this lands
|
|
39
|
+
and releases, then retired per alchemy-provider-standard's retirement steps (drop the kit
|
|
40
|
+
declaration, declare `GitHub.Ruleset` with `adopt(true)`, the fixed read-by-name adopts
|
|
41
|
+
it).
|
|
42
|
+
|
|
43
|
+
## PR 2: `GitHub.RulesetProps` — the fields this house needed and upstream lacks
|
|
44
|
+
|
|
45
|
+
**Target**: same file, same tag.
|
|
46
|
+
|
|
47
|
+
**Fields to add** to `rules.pullRequest`:
|
|
48
|
+
|
|
49
|
+
- `allowedMergeMethods?: ('merge' | 'squash' | 'rebase')[]` — GitHub's own schema
|
|
50
|
+
(`repository-rule-pull-request`, both `@octokit/openapi-types` 27.0.0 and 29.0.1 this
|
|
51
|
+
checkout has read) already carries `allowed_merge_methods`; Alchemy's props do not.
|
|
52
|
+
- `requiredReviewers?: { filePatterns: string[]; minimumApprovals: number; reviewer: {
|
|
53
|
+
id: number; type: 'Team' } }[]` — same schema, `required_reviewers` (beta per GitHub's
|
|
54
|
+
own doc comment, but already live and typed).
|
|
55
|
+
|
|
56
|
+
And to `rules.requiredStatusChecks`:
|
|
57
|
+
|
|
58
|
+
- `doNotEnforceOnCreate?: boolean` — the schema's `do_not_enforce_on_create`.
|
|
59
|
+
|
|
60
|
+
**Deliberately NOT proposed**: `require_extra_approval_for_unattributed_changes`. It
|
|
61
|
+
appears in ZERO of the schemas this house has read — not GitHub's own
|
|
62
|
+
`rest-api-description` at commit `4377b4f4845b` (measured 2026-09-23, 0 occurrences), not
|
|
63
|
+
either installed `@octokit/openapi-types` (27.0.0 or 29.0.1). It is real and round-trips
|
|
64
|
+
live (this estate's own rulesets carry it — LIVE SURVEY 2026-09-23), but proposing a
|
|
65
|
+
field GitHub has not documented anywhere upstream can point to is a different, weaker
|
|
66
|
+
kind of PR. `GitHub.RepositoryRuleset` sends it as an untyped extra key on the request
|
|
67
|
+
body (H15, measured in `repository-ruleset-wire.test.ts`) precisely because it has no
|
|
68
|
+
home in a typed prop upstream would accept today.
|
|
69
|
+
|
|
70
|
+
**Compatibility**: additive, no behavior change for an existing caller.
|
|
71
|
+
|
|
72
|
+
## Sequencing
|
|
73
|
+
|
|
74
|
+
PR 2 is safe to post on its own, any time — it adds optional fields nothing currently
|
|
75
|
+
sets. PR 1 is the one worth discussing with a maintainer first: the adopt-by-name
|
|
76
|
+
behavior change, even though it fixes a bug, is a visible difference for anyone who
|
|
77
|
+
relied on (or never noticed) the duplicate-ruleset behavior.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareRepoBaseline` — the house GitHub baseline (repo-baseline-data.ts), as resources.
|
|
3
|
+
*
|
|
4
|
+
* const baseline = yield* declareRepoBaseline('api', {
|
|
5
|
+
* owner: 'taslabs-net',
|
|
6
|
+
* repository: 'api',
|
|
7
|
+
* checks: ['ci', 'secret scan'],
|
|
8
|
+
* visibility: 'private',
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* Provide `GitHub.providers()` AND `RepositoryRulesetProvider()` — this subpath ships no
|
|
12
|
+
* provider of its own, the same as `declareRepoPolicy`. Both resources are `retain` and
|
|
13
|
+
* `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a baseline this widely
|
|
14
|
+
* shared is meant for a checkout adopting the estate's existing repository and ruleset by name,
|
|
15
|
+
* never for creating one out from under a caller who forgot the flag (H1: a name match is not
|
|
16
|
+
* ours until adopted).
|
|
17
|
+
*
|
|
18
|
+
* ⛔ NO IMPORT FROM `@homeflare/config`, either direction — see `renderRepoShape`'s own header
|
|
19
|
+
* in `packages/config/src/repo-shape/render.ts`. A caller spreads `renderRepoShape(shape)
|
|
20
|
+
* .policy` into these options itself; `checks` only needs to be `readonly string[]`.
|
|
21
|
+
*/
|
|
22
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
23
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
24
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import {
|
|
27
|
+
type RepoBaselineInput,
|
|
28
|
+
repoBaselineRuleset,
|
|
29
|
+
repoBaselineSettings,
|
|
30
|
+
} from './repo-baseline-data.ts';
|
|
31
|
+
import { RepositoryRuleset } from './repository-ruleset.ts';
|
|
32
|
+
|
|
33
|
+
export interface DeclareRepoBaselineOptions extends RepoBaselineInput {}
|
|
34
|
+
|
|
35
|
+
export const declareRepoBaseline = (id: string, options: DeclareRepoBaselineOptions) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const owned = <A, R>(resource: Effect.Effect<A, never, R>) =>
|
|
38
|
+
resource.pipe(RemovalPolicy.retain(), adopt(true));
|
|
39
|
+
const repository = yield* owned(GitHub.Repository(id, repoBaselineSettings(options)));
|
|
40
|
+
const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, repoBaselineRuleset(options)));
|
|
41
|
+
return { repository, ruleset };
|
|
42
|
+
});
|
package/src/github/index.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub helpers for Alchemy — one repository's house policy in one call
|
|
2
|
+
* GitHub helpers for Alchemy — one repository's house policy in one call, and (2026-09-23) the
|
|
3
|
+
* one bridge resource this house builds atop upstream GitHub: `GitHub.RepositoryRuleset`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* ⚠️ `declareRepoPolicy`/`declareRepoBaseline` COMPOSE UPSTREAM `GitHub.Repository` PLUS THIS
|
|
6
|
+
* SUBPATH'S OWN `RepositoryRulesetProvider()` — provide BOTH `GitHub.providers()` and
|
|
7
|
+
* `RepositoryRulesetProvider()`. Before 2026-09-23 this subpath shipped no provider at all,
|
|
8
|
+
* composing only vendor resources; `RepositoryRuleset` is the one exception, and
|
|
9
|
+
* repository-ruleset.ts's header says why upstream's own `Ruleset` cannot fill the gap.
|
|
10
|
+
* ★ WHY THE POLICY HELPERS EXIST. "Open a pull request, let auto-merge land it" is safe only
|
|
11
|
+
* when five repository properties and four ruleset rules all agree, and every way of getting
|
|
12
|
+
* one of them wrong is silent — an auto-merge that lands on red, a force push that rewrites
|
|
13
|
+
* the branch a release tagged, a required context nothing ever reports. One call puts the
|
|
14
|
+
* whole policy in one place, and one place is where it gets fixed.
|
|
12
15
|
*
|
|
13
|
-
* ⛔ THIS BARREL IS THE PUBLIC API. `repo-policy-form.ts`
|
|
14
|
-
*
|
|
16
|
+
* ⛔ THIS BARREL IS THE PUBLIC API. `repo-policy-form.ts`, `repository-ruleset-form.ts` and the
|
|
17
|
+
* rest of the `repository-ruleset-*` internals are reachable by path if you genuinely need
|
|
18
|
+
* one — a deliberate act rather than an accident of barrelling.
|
|
15
19
|
*/
|
|
16
20
|
export {
|
|
17
21
|
type RepoPolicy,
|
|
@@ -21,3 +25,31 @@ export {
|
|
|
21
25
|
repoPolicy,
|
|
22
26
|
} from './repo-policy-form.ts';
|
|
23
27
|
export { type DeclareRepoPolicyOptions, declareRepoPolicy } from './repo-policy.ts';
|
|
28
|
+
export {
|
|
29
|
+
type RepoBaselineInput,
|
|
30
|
+
PUBLIC_ONLY_UNDECLARABLE,
|
|
31
|
+
repoBaselineRuleset,
|
|
32
|
+
repoBaselineSettings,
|
|
33
|
+
} from './repo-baseline-data.ts';
|
|
34
|
+
export { type DeclareRepoBaselineOptions, declareRepoBaseline } from './declare-repo-baseline.ts';
|
|
35
|
+
export {
|
|
36
|
+
RepositoryRuleset,
|
|
37
|
+
RepositoryRulesetProvider,
|
|
38
|
+
type RepositoryRulesetAttributes,
|
|
39
|
+
type RepositoryRulesetProps,
|
|
40
|
+
type RepositoryRulesetPullRequestRule,
|
|
41
|
+
type RepositoryRulesetReviewerRule,
|
|
42
|
+
type RepositoryRulesetRules,
|
|
43
|
+
type RepositoryRulesetStatusChecksRule,
|
|
44
|
+
} from './repository-ruleset.ts';
|
|
45
|
+
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
46
|
+
export {
|
|
47
|
+
BypassActorWidened,
|
|
48
|
+
DuplicateRuleset,
|
|
49
|
+
NeverReportedContext,
|
|
50
|
+
ReadbackMismatch,
|
|
51
|
+
RequiredChecksOmitted,
|
|
52
|
+
RulesetConstraintRefused,
|
|
53
|
+
UndeclaredLiveRule,
|
|
54
|
+
type RepositoryRulesetError,
|
|
55
|
+
} from './repository-ruleset-errors.ts';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the house baseline VALUES so a loosening (an extra merge method, a dropped `deletion`
|
|
3
|
+
* rule, `extraApprovalForUnattributedChanges` flipped) shows up as a reviewed diff in THIS
|
|
4
|
+
* file, not as a silent change to every repo that adopts `declareRepoBaseline`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { repoBaselineRuleset, repoBaselineSettings } from './repo-baseline-data.ts';
|
|
8
|
+
|
|
9
|
+
const input = {
|
|
10
|
+
owner: 'taslabs-net',
|
|
11
|
+
repository: 'widgets',
|
|
12
|
+
checks: ['ci', 'secret scan'],
|
|
13
|
+
visibility: 'private' as const,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('repoBaselineSettings', () => {
|
|
17
|
+
test('squash-only, auto-merge on, wiki off — identical for private and public', () => {
|
|
18
|
+
expect(repoBaselineSettings(input)).toEqual({
|
|
19
|
+
owner: 'taslabs-net',
|
|
20
|
+
name: 'widgets',
|
|
21
|
+
visibility: 'private',
|
|
22
|
+
allowSquashMerge: true,
|
|
23
|
+
allowMergeCommit: false,
|
|
24
|
+
allowRebaseMerge: false,
|
|
25
|
+
allowAutoMerge: true,
|
|
26
|
+
deleteBranchOnMerge: true,
|
|
27
|
+
hasWiki: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a public repo gets the same settings, only visibility differs', () => {
|
|
32
|
+
const publicSettings = repoBaselineSettings({ ...input, visibility: 'public' });
|
|
33
|
+
const privateSettings = repoBaselineSettings(input);
|
|
34
|
+
expect({ ...publicSettings, visibility: undefined }).toEqual({
|
|
35
|
+
...privateSettings,
|
|
36
|
+
visibility: undefined,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('repoBaselineRuleset', () => {
|
|
42
|
+
test('pins the exact ruleset shape the GitHub-baseline plan specifies', () => {
|
|
43
|
+
expect(repoBaselineRuleset(input)).toEqual({
|
|
44
|
+
owner: 'taslabs-net',
|
|
45
|
+
repository: 'widgets',
|
|
46
|
+
name: 'main',
|
|
47
|
+
target: 'branch',
|
|
48
|
+
enforcement: 'active',
|
|
49
|
+
bypassActors: [],
|
|
50
|
+
conditions: { include: ['~DEFAULT_BRANCH'], exclude: [] },
|
|
51
|
+
rules: {
|
|
52
|
+
deletion: true,
|
|
53
|
+
nonFastForward: true,
|
|
54
|
+
pullRequest: {
|
|
55
|
+
requiredApprovingReviewCount: 0,
|
|
56
|
+
dismissStaleReviewsOnPush: true,
|
|
57
|
+
requireCodeOwnerReview: false,
|
|
58
|
+
requireLastPushApproval: false,
|
|
59
|
+
requiredReviewThreadResolution: false,
|
|
60
|
+
allowedMergeMethods: ['squash'],
|
|
61
|
+
extraApprovalForUnattributedChanges: false,
|
|
62
|
+
},
|
|
63
|
+
requiredStatusChecks: {
|
|
64
|
+
checks: [{ context: 'ci' }, { context: 'secret scan' }],
|
|
65
|
+
strictRequiredStatusChecksPolicy: false,
|
|
66
|
+
doNotEnforceOnCreate: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no checks (homeflare/anyauth/desktop today) omits requiredStatusChecks entirely', () => {
|
|
73
|
+
const ruleset = repoBaselineRuleset({ ...input, checks: [] });
|
|
74
|
+
expect(ruleset.rules.requiredStatusChecks).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The house GitHub baseline, as plain data — the VALUES section of the GitHub-baseline plan
|
|
3
|
+
* (2026-09-23), and nothing else. No provider import (S9's "pure core" pattern, the same split
|
|
4
|
+
* repo-policy-form.ts uses): `declare-repo-baseline.ts` turns this into resources, and
|
|
5
|
+
* `hf-repo-baseline-check.ts` reads it read-only against a live repo. Both import THIS file so
|
|
6
|
+
* neither can drift from the other.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ IDENTICAL FOR PRIVATE AND PUBLIC, except the fields this module marks `publicOnly` —
|
|
9
|
+
* `security_and_analysis` is `null` on every private repo this estate has (LIVE SURVEY,
|
|
10
|
+
* 2026-09-23), so secret scanning / push protection / private vulnerability reporting are
|
|
11
|
+
* not settable there at all, not merely off.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RepoBaselineInput {
|
|
15
|
+
readonly owner: string;
|
|
16
|
+
readonly repository: string;
|
|
17
|
+
readonly checks: readonly string[];
|
|
18
|
+
readonly visibility: 'public' | 'private';
|
|
19
|
+
readonly description?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `GitHub.Repository` fields this baseline owns. Every merge-automation caller across the 17
|
|
23
|
+
* tray checkouts uses `--squash` (measured 2026-09-23; the only non-squash mentions are
|
|
24
|
+
* GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice. */
|
|
25
|
+
export function repoBaselineSettings(input: RepoBaselineInput) {
|
|
26
|
+
return {
|
|
27
|
+
owner: input.owner,
|
|
28
|
+
name: input.repository,
|
|
29
|
+
...(input.description === undefined ? {} : { description: input.description }),
|
|
30
|
+
visibility: input.visibility,
|
|
31
|
+
allowSquashMerge: true,
|
|
32
|
+
allowMergeCommit: false,
|
|
33
|
+
allowRebaseMerge: false,
|
|
34
|
+
allowAutoMerge: true,
|
|
35
|
+
deleteBranchOnMerge: true,
|
|
36
|
+
hasWiki: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `GitHub.RepositoryRuleset` `main` — target branch, 0-approval solo-maintainer shape, the
|
|
41
|
+
* named `checks` required, squash-only at the ruleset level too (H15's whole reason to exist:
|
|
42
|
+
* the repository setting can be re-ticked in the UI; the ruleset is what actually enforces it). */
|
|
43
|
+
export function repoBaselineRuleset(input: RepoBaselineInput) {
|
|
44
|
+
return {
|
|
45
|
+
owner: input.owner,
|
|
46
|
+
repository: input.repository,
|
|
47
|
+
name: 'main',
|
|
48
|
+
target: 'branch' as const,
|
|
49
|
+
enforcement: 'active' as const,
|
|
50
|
+
bypassActors: [],
|
|
51
|
+
conditions: { include: ['~DEFAULT_BRANCH'], exclude: [] },
|
|
52
|
+
rules: {
|
|
53
|
+
deletion: true,
|
|
54
|
+
nonFastForward: true,
|
|
55
|
+
pullRequest: {
|
|
56
|
+
requiredApprovingReviewCount: 0,
|
|
57
|
+
dismissStaleReviewsOnPush: true,
|
|
58
|
+
requireCodeOwnerReview: false,
|
|
59
|
+
requireLastPushApproval: false,
|
|
60
|
+
requiredReviewThreadResolution: false,
|
|
61
|
+
allowedMergeMethods: ['squash' as const],
|
|
62
|
+
extraApprovalForUnattributedChanges: false as const,
|
|
63
|
+
},
|
|
64
|
+
...(input.checks.length === 0
|
|
65
|
+
? {}
|
|
66
|
+
: {
|
|
67
|
+
requiredStatusChecks: {
|
|
68
|
+
checks: input.checks.map((context) => ({ context })),
|
|
69
|
+
strictRequiredStatusChecksPolicy: false,
|
|
70
|
+
doNotEnforceOnCreate: false,
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Public-only settings this baseline asserts but cannot express through either resource above
|
|
78
|
+
* at `alchemy@2.0.0-beta.79` — recorded for the wave-3 handoff artifact (plan section 2), not
|
|
79
|
+
* declared anywhere. Both already measured ON for `kit` (LIVE SURVEY 2026-09-23); `builds` is
|
|
80
|
+
* the gap. */
|
|
81
|
+
export const PUBLIC_ONLY_UNDECLARABLE = [
|
|
82
|
+
'secret_scanning: enabled',
|
|
83
|
+
'secret_scanning_push_protection: enabled',
|
|
84
|
+
'private_vulnerability_reporting: enabled',
|
|
85
|
+
"fork-PR approval: 'first_time_contributors'",
|
|
86
|
+
] as const;
|
|
@@ -7,21 +7,19 @@
|
|
|
7
7
|
* checks: ['ci', 'secret scan'],
|
|
8
8
|
* });
|
|
9
9
|
*
|
|
10
|
-
* Provide `GitHub.providers()`; this subpath adds no
|
|
10
|
+
* Provide `GitHub.providers()` AND `RepositoryRulesetProvider()`; this subpath adds no GitHub
|
|
11
|
+
* provider of its own.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name
|
|
23
|
-
* and converges onto whatever is live — so only half of this helper is adopt-safe,
|
|
24
|
-
* and the halves fail differently. See docs/repo-policy.md.
|
|
13
|
+
* ★ REWIRED 2026-09-23 onto `GitHub.RepositoryRuleset`, not upstream `GitHub.Ruleset` — the
|
|
14
|
+
* ADOPT-SAFE bridge documented in repository-ruleset.ts. Only `builds` calls this, and its
|
|
15
|
+
* ruleset has NEVER been created (LIVE SURVEY 2026-09-23: 0 rulesets live on `builds`), so
|
|
16
|
+
* the swap changes no live resource's identity; a cold first deploy is the only deploy this
|
|
17
|
+
* call has ever had. `homeflare/docs/repo-policy-ruleset-hazards.md`'s "before the first
|
|
18
|
+
* deploy, check by hand" workaround is now enforced by the resource itself (the name-probe in
|
|
19
|
+
* repository-ruleset-probe.ts) rather than left to the operator — see that doc for the
|
|
20
|
+
* history, kept rather than deleted so the hazard `builds` almost hit stays legible.
|
|
21
|
+
* ⚠️ `GitHub.Repository` keeps its OWN upstream behavior unchanged — it already probed by name
|
|
22
|
+
* and converged, so only the ruleset half of this helper needed the swap.
|
|
25
23
|
*
|
|
26
24
|
* ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
|
|
27
25
|
* is piped anyway so that a vendor change cannot quietly turn "drop this call from the
|
|
@@ -32,12 +30,12 @@ import * as GitHub from 'alchemy/GitHub';
|
|
|
32
30
|
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
33
31
|
import * as Effect from 'effect/Effect';
|
|
34
32
|
import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
|
|
33
|
+
import { RepositoryRuleset } from './repository-ruleset.ts';
|
|
35
34
|
|
|
36
35
|
export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
|
|
37
36
|
/**
|
|
38
37
|
* `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
|
|
39
|
-
* decides.
|
|
40
|
-
* what a provider's `read` reports, and this one reports nothing without prior state.
|
|
38
|
+
* decides.
|
|
41
39
|
*/
|
|
42
40
|
readonly adopt?: boolean;
|
|
43
41
|
}
|
|
@@ -60,6 +58,8 @@ export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions)
|
|
|
60
58
|
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
61
59
|
};
|
|
62
60
|
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
63
|
-
|
|
61
|
+
// `policy.ruleset` is a plain `RulesetProps` — every field `RepositoryRulesetProps` adds
|
|
62
|
+
// over it is optional, so it needs no translation to satisfy the bridge's props.
|
|
63
|
+
const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, policy.ruleset));
|
|
64
64
|
return { policy, repository, ruleset };
|
|
65
65
|
});
|