@homeflare/alchemy 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js.map +2 -2
  34. package/dist/launchd/port-claims.d.ts +2 -2
  35. package/dist/launchd/port-claims.d.ts.map +1 -1
  36. package/docs/launchd-ports.md +14 -17
  37. package/docs/repo-policy-ruleset-hazards.md +8 -0
  38. package/docs/repo-policy.md +27 -12
  39. package/docs/repository-ruleset.md +114 -0
  40. package/docs/upstream-conformance.md +12 -1
  41. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  42. package/package.json +1 -1
  43. package/src/github/declare-repo-baseline.ts +42 -0
  44. package/src/github/index.ts +43 -11
  45. package/src/github/repo-baseline-data.test.ts +76 -0
  46. package/src/github/repo-baseline-data.ts +86 -0
  47. package/src/github/repo-policy.ts +17 -17
  48. package/src/github/repository-ruleset-constraints.ts +136 -0
  49. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  50. package/src/github/repository-ruleset-errors.ts +136 -0
  51. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  52. package/src/github/repository-ruleset-fixtures.ts +183 -0
  53. package/src/github/repository-ruleset-form.ts +212 -0
  54. package/src/github/repository-ruleset-guards.ts +194 -0
  55. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  56. package/src/github/repository-ruleset-octokit.ts +140 -0
  57. package/src/github/repository-ruleset-probe.ts +116 -0
  58. package/src/github/repository-ruleset-reconcile.ts +176 -0
  59. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  60. package/src/github/repository-ruleset-wire.test.ts +66 -0
  61. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  62. package/src/github/repository-ruleset-write.test.ts +83 -0
  63. package/src/github/repository-ruleset.ts +219 -0
  64. package/src/launchd/port-claims.ts +8 -9
@@ -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.26.0",
3
+ "version": "0.27.1",
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
+ });
@@ -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
- * ⛔ 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.
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` is reachable by path if you
14
- * genuinely need an internal — a deliberate act rather than an accident of barrelling.
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 provider of its own.
10
+ * Provide `GitHub.providers()` AND `RepositoryRulesetProvider()`; this subpath adds no GitHub
11
+ * provider of its own.
11
12
  *
12
- * ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE
13
- * MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22
14
- * (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever
15
- * there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN
16
- * THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits
17
- * several rulesets with the same name on one repository, so nothing errors; the repo
18
- * quietly ends up with two, both enforcing.
19
- * ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a
20
- * hand-made ruleset of the same name, or give this one a `rulesetName` of its own.
21
- * Afterwards the state carries the id and every later deploy converges that one.
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. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
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
- const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
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
  });