@homeflare/alchemy 0.12.0 → 0.14.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/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -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/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write-only.d.ts","sourceRoot":"","sources":["../../src/proxmox/write-only.ts"],"names":[],"mappings":"AA2BA,+FAA+F;AAC/F,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,+FAA+F;AAC/F,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAEvE;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SACf,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,QAClC,WAAW,KACf;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CAS1F,CAAC;AAqBF;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,WAAY,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,SAAS,MAAM,KAAG,MAI9E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,WAAY,MAAM,UAAU,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAAG,OAStF,CAAC"}
|
|
@@ -53,6 +53,11 @@ export interface FakeEngine {
|
|
|
53
53
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
54
54
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
55
55
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
56
|
+
/**
|
|
57
|
+
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
58
|
+
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
59
|
+
*/
|
|
60
|
+
readonly stored: () => string;
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
58
63
|
* One stack over one state store and any providers, shared by every call — a family's own test
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,UAyC3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
|
package/docs/adopted-deploys.md
CHANGED
|
@@ -38,6 +38,7 @@ API token on the cluster, just as it does for `alchemy plan`.
|
|
|
38
38
|
| `Proxmox.BackupJob` | `cluster/backup/{id}` | read | shared path — no write |
|
|
39
39
|
| `Proxmox.MetricServer` | `cluster/metrics/server/{id}` | read | shared path — no write |
|
|
40
40
|
| `Proxmox.NotificationTarget` ² | `cluster/notifications/endpoints/{type}/{name}` | read | shared path — no write |
|
|
41
|
+
| `Proxmox.NotificationMatcher` | `cluster/notifications/matchers/{name}` | read | shared path — no write |
|
|
41
42
|
| `Proxmox.HaResource` | `cluster/ha/resources/{sid}` | read | shared path — no write |
|
|
42
43
|
| `Proxmox.HaRule` | `cluster/ha/rules/{rule}` | read | shared path — no write |
|
|
43
44
|
| `Proxmox.SdnZone` | `cluster/sdn/zones/{zone}` | provision ¹ | shared path — no write |
|
|
@@ -47,6 +48,8 @@ API token on the cluster, just as it does for `alchemy plan`.
|
|
|
47
48
|
| `Pbs.PruneJob` | `config/prune/{id}` | read (PBS) | shared path — no write |
|
|
48
49
|
| `Pbs.SyncJob` | `config/sync/{id}` | read (PBS) | shared path — no write |
|
|
49
50
|
| `Pbs.VerifyJob` | `config/verify/{id}` | read (PBS) | shared path — no write |
|
|
51
|
+
| `Pbs.NotificationMatcher` | `config/notifications/matchers/{name}` | read (PBS) | shared path — no write |
|
|
52
|
+
| `Pbs.NotificationTarget` ⁵ | `config/notifications/endpoints/{type}/{name}` | read (PBS) | own: GET, PUT only for a stale group, GET — no write |
|
|
50
53
|
| `Proxmox.Acl` | `access/acl` (whole list, filtered) | provision ¹ | shared path, then its own `bound` check — no write |
|
|
51
54
|
| `Pbs.Datastore` | `config/datastore/{name}` | read (PBS) | own: GET, path/backend guards, PUT skipped on `matches`, settle GET — no write |
|
|
52
55
|
| `Proxmox.SdnApply` | 5 × `cluster/sdn/*?pending=1` + fabrics `?pending` / `?running` | read | own: counts staged objects; zero returns at once — **no `PUT /cluster/sdn`** |
|
|
@@ -65,6 +68,11 @@ no-write row applies. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with n
|
|
|
65
68
|
declared ratio that differs from the live one is never written on a no-op adoption and never
|
|
66
69
|
reported as a diff.
|
|
67
70
|
|
|
71
|
+
⁵ Its own `diff` and `reconcile`, because a secret's value is not in the live object: the plan
|
|
72
|
+
compares a seal kept in the previous state ([pbs-notifications.md](./pbs-notifications.md)). An
|
|
73
|
+
adopted target has no seal, so its secret values are presence-only and adoption writes nothing.
|
|
74
|
+
Pinned by `src/proxmox/pbs-notification-target-state.test.ts`.
|
|
75
|
+
|
|
68
76
|
## Before the fix: `Proxmox.CephPool`
|
|
69
77
|
|
|
70
78
|
`reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# A PBS webhook body that posts an Alertmanager alert
|
|
2
|
+
|
|
3
|
+
`alertmanagerAlertBody()` ([alertmanager-body.ts](../src/proxmox/alertmanager-body.ts)) is the
|
|
4
|
+
body of a PBS webhook target that posts one alert to Alertmanager's `POST /api/v2/alerts`.
|
|
5
|
+
Declaring the target and the matcher that routes to it: [pbs-notifications.md](./pbs-notifications.md).
|
|
6
|
+
|
|
7
|
+
With the default options it returns exactly this text:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
"labels": {
|
|
13
|
+
{{#if fields.type}}"job_type": {{ json fields.type }},{{/if}}
|
|
14
|
+
{{#if fields.job-id}}"job_id": {{ json fields.job-id }},{{/if}}
|
|
15
|
+
{{#if fields.datastore}}"datastore": {{ json fields.datastore }},{{/if}}
|
|
16
|
+
{{#if fields.hostname}}"hostname": {{ json fields.hostname }},{{/if}}
|
|
17
|
+
"severity": {{ json severity }},
|
|
18
|
+
"alertname": "PbsNotification",
|
|
19
|
+
"source": "pbs"
|
|
20
|
+
},
|
|
21
|
+
"annotations": {
|
|
22
|
+
"summary": {{ json title }},
|
|
23
|
+
"description": {{ json message }},
|
|
24
|
+
"timestamp": "{{ timestamp }}"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The fence is `text` on purpose. As a `handlebars` fence the formatter reflowed it into
|
|
31
|
+
something the function does not return.
|
|
32
|
+
|
|
33
|
+
Options: `alertname`, `source` and an optional `generatorURL`. Braces and backslashes are
|
|
34
|
+
refused in all three. `generatorURL` must also be an absolute `http` or `https` URL, because
|
|
35
|
+
Alertmanager validates it as a URI and rejects the whole post if it is not one.
|
|
36
|
+
|
|
37
|
+
## Why it is valid JSON for every PBS notification
|
|
38
|
+
|
|
39
|
+
Each point is from the proxmox-notify, proxmox-backup and Alertmanager source, read 2026-09-22.
|
|
40
|
+
|
|
41
|
+
- **The webhook renderer has no escaping** (`register_escape_fn(no_escape)`). Every value that
|
|
42
|
+
comes from the notification goes through `json`, which is `serde_json::to_string`. It writes
|
|
43
|
+
a complete, quoted JSON string with quotes, backslashes and control characters escaped. A task
|
|
44
|
+
log with a quote, a tab or a newline stays valid JSON.
|
|
45
|
+
- **`escape` on a missing field fails the whole render**, and the notification is lost.
|
|
46
|
+
`json` on a missing field writes `null`, which is valid JSON but not a label value. So every
|
|
47
|
+
`fields.*` label sits inside `{{#if}}` and ends with its own comma, and the constant labels
|
|
48
|
+
close the object. The UI's **Test** button sends a notification with no fields at all.
|
|
49
|
+
- **Every field value is a string.** proxmox-notify turns each metadata field into a JSON string
|
|
50
|
+
before rendering. `severity` serialises as `info`, `notice`, `warning`, `error` or `unknown`,
|
|
51
|
+
and `timestamp` is an integer (epoch seconds). Nothing else in the data is rendered.
|
|
52
|
+
- **The helpers are `json`, `escape` and `url-encode`, and nothing else.** The mail templates'
|
|
53
|
+
`timestamp` and `duration` helpers are not registered for webhooks, so `timestamp` is sent as
|
|
54
|
+
epoch seconds in an annotation.
|
|
55
|
+
- **Alertmanager takes an array** (`postableAlerts`). `labels` is required and must not be empty.
|
|
56
|
+
Every label and annotation value must be a string. It drops empty labels.
|
|
57
|
+
|
|
58
|
+
`tests/alertmanager-body.test.ts` renders the template with handlebars.js set up the way
|
|
59
|
+
proxmox-notify sets up its renderer. It covers the field set of every event type in PBS's
|
|
60
|
+
`src/server/notifications/mod.rs`, the Test notification, forwarded system mail, and hostile
|
|
61
|
+
strings (quotes, backslashes, control characters, `{{`, U+2028). Each result must parse as an
|
|
62
|
+
Alertmanager v2 alert array. A scratch build of handlebars-rust 5.1.2, the major version
|
|
63
|
+
proxmox-notify pins, rendered the same cases to valid JSON. The first real notification is
|
|
64
|
+
still the check that counts: press **Test** on the target, then read what Alertmanager received.
|
|
65
|
+
|
|
66
|
+
## What it does not do
|
|
67
|
+
|
|
68
|
+
- **With no `endsAt`, Alertmanager resolves the alert itself** after `resolve_timeout` (5 min by
|
|
69
|
+
default), and PBS never sends a resolve. Treat a "resolved" notification as a timeout, not a
|
|
70
|
+
recovery.
|
|
71
|
+
- **`severity` uses PBS's names.** Route on `severity="error"`, or relabel in Alertmanager.
|
|
72
|
+
- **Alertmanager deduplicates by label set.** Two failures of the same job within
|
|
73
|
+
`resolve_timeout` are one alert, not two.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# PBS and PVE notifications — targets, matchers, and an Alertmanager webhook
|
|
2
|
+
|
|
3
|
+
`@homeflare/alchemy/proxmox` declares both halves of the Proxmox notification system:
|
|
4
|
+
|
|
5
|
+
| family | what it is | source |
|
|
6
|
+
| ----------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
7
|
+
| `Pbs.NotificationTarget` | where PBS sends: `webhook`, `smtp`, `sendmail` | [pbs-notification-target.ts](../src/proxmox/pbs-notification-target.ts) |
|
|
8
|
+
| `Pbs.NotificationMatcher` | which PBS notifications go to which targets | [pbs-notification-matcher.ts](../src/proxmox/pbs-notification-matcher.ts) |
|
|
9
|
+
| `Proxmox.NotificationMatcher` | the same on a PVE cluster | [notification-matcher.ts](../src/proxmox/notification-matcher.ts) |
|
|
10
|
+
| `Proxmox.NotificationTarget` | PVE targets (existing; declares no secret) | [notification-target.ts](../src/proxmox/notification-target.ts) |
|
|
11
|
+
| `alertmanagerAlertBody()` | a webhook body that posts one Alertmanager v2 alert | [pbs-alertmanager-body.md](./pbs-alertmanager-body.md) |
|
|
12
|
+
|
|
13
|
+
A target that no matcher names receives nothing. Declare both.
|
|
14
|
+
|
|
15
|
+
## Paging on a failed verify, sync, prune or garbage collection
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import {
|
|
20
|
+
PbsNotificationMatcher,
|
|
21
|
+
PbsNotificationTarget,
|
|
22
|
+
alertmanagerAlertBody,
|
|
23
|
+
} from '@homeflare/alchemy/proxmox';
|
|
24
|
+
|
|
25
|
+
const pbs = {
|
|
26
|
+
api: 'https://pbs.example.com:8007/api2/json',
|
|
27
|
+
mount: 'pbs-mount',
|
|
28
|
+
scheme: 'pbs',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const notifications = Effect.gen(function* () {
|
|
32
|
+
const hook = yield* PbsNotificationTarget('alertmanager', {
|
|
33
|
+
target: pbs,
|
|
34
|
+
type: 'webhook',
|
|
35
|
+
name: 'alertmanager',
|
|
36
|
+
method: 'post',
|
|
37
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
38
|
+
header: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Authorization: 'Bearer {{ secrets.token }}', // template text, not the token
|
|
41
|
+
},
|
|
42
|
+
secret: { token: { fromEnv: 'PBS_ALERTMANAGER_TOKEN' } }, // the token, by variable name
|
|
43
|
+
body: alertmanagerAlertBody(),
|
|
44
|
+
});
|
|
45
|
+
yield* PbsNotificationMatcher('page-on-failure', {
|
|
46
|
+
target: pbs,
|
|
47
|
+
name: 'page-on-failure',
|
|
48
|
+
targets: [hook.name], // the Output, not a string: it orders create and delete
|
|
49
|
+
'match-severity': ['error'],
|
|
50
|
+
'match-field': ['exact:type=gc,prune,sync,verify'],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Drop the `Authorization` header and the `secret` when Alertmanager takes unauthenticated posts
|
|
56
|
+
on a trusted network. The provider layer is `PbsNotificationTargetProvider()` and
|
|
57
|
+
`PbsNotificationMatcherProvider()`. `targets: [hook.name]` matters: PBS refuses a matcher that
|
|
58
|
+
names a missing target, and refuses to delete a target a matcher still names. The Output gives
|
|
59
|
+
Alchemy that ordering; a bare string does not (`pbs-notifications-stack.test.ts`).
|
|
60
|
+
|
|
61
|
+
⛔ **A datastore in `legacy-sendmail` mode never reaches a matcher.** PBS mails its `notify-user`
|
|
62
|
+
directly. `notification-system` is the default, but a datastore created before PBS 3.2 can say
|
|
63
|
+
otherwise. Check with `proxmox-backup-manager datastore show <name>`.
|
|
64
|
+
|
|
65
|
+
⚠️ **A verification job's event `type` is `verify`**, not `verification`. PBS's own event table
|
|
66
|
+
says `verification`, and the source (`send_verify_status`) sets `verify`. The other types are
|
|
67
|
+
`gc`, `prune`, `sync`, `tape-backup`, `tape-load`, `package-updates`, `acme`, `thresholds` and
|
|
68
|
+
`system-mail`. Garbage collection has no `job-id`.
|
|
69
|
+
|
|
70
|
+
⚠️ **`match-severity: ['warning', 'error']` under the default `mode: 'all'` matches nothing.**
|
|
71
|
+
Each item is combined with `mode`. The severities inside one item are OR-ed, so write
|
|
72
|
+
`['warning,error']`.
|
|
73
|
+
|
|
74
|
+
## Write-only values: `{ fromEnv }`
|
|
75
|
+
|
|
76
|
+
Alchemy stores props and attributes unencrypted, and a `Redacted` value is tagged, not
|
|
77
|
+
encrypted. A secret prop would be a secret in the state database. So `secret`, the smtp
|
|
78
|
+
`password` and any header value that is a credential are declared as `{ fromEnv: 'NAME' }`.
|
|
79
|
+
The deploying process reads the variable at call time.
|
|
80
|
+
|
|
81
|
+
| what | in state | how a plan compares it |
|
|
82
|
+
| --------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
83
|
+
| secret values | names (PBS returns names only), and a **seal** | seal of what this provider last wrote, checked against the env value |
|
|
84
|
+
| smtp password | the seal only (PBS returns nothing) | the same seal |
|
|
85
|
+
| header values | names, and a scrypt digest of the live pairs | digest of the declared values (env-resolved) against the digest of the live ones |
|
|
86
|
+
| everything else | the value | by value, like every family here |
|
|
87
|
+
|
|
88
|
+
The seal is `scrypt:<salt>:<digest>`, with a random salt per write. It proves "unchanged" and
|
|
89
|
+
holds nothing a reader could send to PBS. A guessable secret stays guessable, so use a random
|
|
90
|
+
token.
|
|
91
|
+
|
|
92
|
+
- **A plan without the variables is presence-only.** Names are compared and values are
|
|
93
|
+
`unknown`. `unknown` plans `noop`, never an invented update.
|
|
94
|
+
- **A write carries only the stale groups.** A PUT without `secret` or `header` leaves those on
|
|
95
|
+
the server as they are, so a changed comment deploys without the secrets.
|
|
96
|
+
- **A write that needs a missing variable fails by name**, before any request is sent.
|
|
97
|
+
- **Adoption is free.** A webhook adopted with secrets has no seal. Its values stay unverified
|
|
98
|
+
(presence-only) until the first write that carries them: a create, a changed secret name, or
|
|
99
|
+
a value rotated after that first write. A value the server already holds is never replaced by
|
|
100
|
+
adoption alone. To write yours, rename the secret once, together with its `{{ secrets.* }}`
|
|
101
|
+
references.
|
|
102
|
+
- **Rotation.** Change the variable's value and plan: the seal no longer matches, and the deploy
|
|
103
|
+
PUTs the secret group alone.
|
|
104
|
+
|
|
105
|
+
⛔ **A literal credential in a plain prop is refused.** A plain prop is stored as written, so an
|
|
106
|
+
`Authorization`, `Proxy-Authorization` or `Cookie` header, a header or URL query parameter
|
|
107
|
+
named like `token`, `key`, `secret`, `password` or `signature`, or a password in the URL's
|
|
108
|
+
userinfo must be `{ fromEnv }` or read `{{ secrets.<name> }}`. The plan fails and names the
|
|
109
|
+
field, never the value.
|
|
110
|
+
|
|
111
|
+
⚠️ **What the guard cannot see.** A token in a URL path (Slack-style webhooks), in `body` or in
|
|
112
|
+
`comment` is still a plain prop. Put it in `secret` and reference it from the template. The
|
|
113
|
+
check runs at plan only while every prop is resolved. If a prop is an unresolved Output, Alchemy
|
|
114
|
+
records the props before `reconcile` refuses them.
|
|
115
|
+
|
|
116
|
+
⚠️ **Prefer `secret` over a `{ fromEnv }` header for a credential.** PBS returns header values
|
|
117
|
+
to anyone with `Sys.Audit`, and prints a header's value unmasked when it fails to render. It
|
|
118
|
+
masks only `secret` values.
|
|
119
|
+
|
|
120
|
+
## The body template
|
|
121
|
+
|
|
122
|
+
`alertmanagerAlertBody()` has its own page, with the exact text it returns and why it is valid
|
|
123
|
+
JSON for every notification PBS sends: [pbs-alertmanager-body.md](./pbs-alertmanager-body.md).
|
|
124
|
+
|
|
125
|
+
## Adopting the built-in `default-matcher`
|
|
126
|
+
|
|
127
|
+
A matcher is compared as its whole rule: `match-*`, `mode`, `invert-match` and `disable`,
|
|
128
|
+
declared or not. Only `comment` is left alone when undeclared. Adopting a built-in means
|
|
129
|
+
declaring what it holds. Measured 2026-09-22:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// PVE 9.2.11: everything to mail-to-root
|
|
133
|
+
ProxmoxNotificationMatcher('default-matcher', {
|
|
134
|
+
target: cluster,
|
|
135
|
+
name: 'default-matcher',
|
|
136
|
+
targets: ['mail-to-root'],
|
|
137
|
+
});
|
|
138
|
+
// PBS 4.2: everything except successful prune jobs
|
|
139
|
+
PbsNotificationMatcher('default-matcher', {
|
|
140
|
+
target: pbs,
|
|
141
|
+
name: 'default-matcher',
|
|
142
|
+
targets: ['mail-to-root'],
|
|
143
|
+
'invert-match': true,
|
|
144
|
+
'match-field': ['exact:type=prune'],
|
|
145
|
+
'match-severity': ['info'],
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both plan `noop` and deploy with no write (`notification-matcher.test.ts`). ⛔ **Deleting a
|
|
150
|
+
built-in reverts it to the shipped rule**, and the server reports success. The attributes show
|
|
151
|
+
`origin` so you can see which kind you have.
|
|
152
|
+
|
|
153
|
+
## Privileges
|
|
154
|
+
|
|
155
|
+
| server | read | write |
|
|
156
|
+
| ------ | --------------------------------------------------------------- | -------------------------------------------- |
|
|
157
|
+
| PBS | `Sys.Audit` on `/system/notifications` | `Sys.Modify` on `/system/notifications` |
|
|
158
|
+
| PVE | `Mapping.Audit` or `Mapping.Modify` on `/mapping/notifications` | `Mapping.Modify` on `/mapping/notifications` |
|
|
159
|
+
|
|
160
|
+
⚠️ A missing read privilege looks like "absent". The plan says `create` and the server answers
|
|
161
|
+
that the object already exists. Grant the read before changing anything else. On PVE,
|
|
162
|
+
`Mapping.Use` is not enough: it opens the matcher list, not the per-matcher read this package
|
|
163
|
+
makes (`get_matcher` in pve-manager's `Notifications.pm`).
|
package/docs/proxmox.md
CHANGED
|
@@ -15,6 +15,9 @@ or creates one from a template.
|
|
|
15
15
|
- ★ **One provisioning baseline for every cluster and node**: the privilege list, a helper that
|
|
16
16
|
declares it, and the one-time root commands that bootstrap it
|
|
17
17
|
([provision-baseline.md](./provision-baseline.md)).
|
|
18
|
+
- ★ **Notifications that page**: PBS webhook, smtp and sendmail targets with write-only secrets
|
|
19
|
+
(`{ fromEnv }`, never in state), PBS and PVE matchers, and a webhook body that posts an
|
|
20
|
+
Alertmanager v2 alert ([pbs-notifications.md](./pbs-notifications.md)).
|
|
18
21
|
- ★ **Measurements in the source name a reference cluster with placeholders**: `C1`, three nodes
|
|
19
22
|
`node-b`, `node-c` and `node-d` with Ceph on a Thunderbolt mesh, and documentation addresses
|
|
20
23
|
(RFC 5737). The shapes and counts are measured; the names and addresses are not the real ones.
|
|
@@ -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.14.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';
|