reposets 0.4.2 → 1.0.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 +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +364 -91
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +471 -1499
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/bin/reposets.d.ts +0 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- package/services/SyncEngine.js +0 -580
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
//#region src/sync/phases/resource.ts
|
|
6
|
+
/**
|
|
7
|
+
* Resolve one secret or variable group into named values.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The three kinds are exclusive by schema, so this branches once rather than
|
|
11
|
+
* merging. Values come back {@link Redacted.Redacted} regardless of kind — a
|
|
12
|
+
* variable is not secret, but a uniform return type means no call site has to
|
|
13
|
+
* decide, and unwrapping stays a deliberate `Redacted.value` at the write.
|
|
14
|
+
*
|
|
15
|
+
* `value` entries may be a string or arbitrary JSON. A string is used as-is and
|
|
16
|
+
* anything else is `JSON.stringify`d, which is what v3 did and what the config
|
|
17
|
+
* schema's `string | Json` implies — GitHub stores a string either way.
|
|
18
|
+
*
|
|
19
|
+
* `file` paths resolve against `ctx.configDir` — the directory the config was
|
|
20
|
+
* loaded from, threaded by the engine — never against the working directory. A
|
|
21
|
+
* relative path resolved against `cwd` would read a different file depending on
|
|
22
|
+
* where the CLI happened to be invoked, silently, and into a secret.
|
|
23
|
+
*
|
|
24
|
+
* A file that cannot be read is reported with its **path**, never its contents:
|
|
25
|
+
* this runs on the secret-loading path, and the error is the most likely thing
|
|
26
|
+
* to reach a log.
|
|
27
|
+
*/
|
|
28
|
+
const resolveOne = (group, label, ctx) => {
|
|
29
|
+
const entries = /* @__PURE__ */ new Map();
|
|
30
|
+
const errors = [];
|
|
31
|
+
if ("value" in group) {
|
|
32
|
+
for (const [name, value] of Object.entries(group.value)) entries.set(name, Redacted.make(typeof value === "string" ? value : JSON.stringify(value), { label: name }));
|
|
33
|
+
return {
|
|
34
|
+
entries,
|
|
35
|
+
errors
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if ("resolved" in group) {
|
|
39
|
+
for (const [name, credentialLabel] of Object.entries(group.resolved)) {
|
|
40
|
+
const value = ctx.credentials.get(credentialLabel);
|
|
41
|
+
if (value === void 0) {
|
|
42
|
+
errors.push({
|
|
43
|
+
context: `${label}.${name}`,
|
|
44
|
+
message: `credential label '${credentialLabel}' is not defined in the active profile's [resolve] section`
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
entries.set(name, value);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
entries,
|
|
52
|
+
errors
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
for (const [name, filePath] of Object.entries(group.file)) {
|
|
56
|
+
const fullPath = isAbsolute(filePath) ? filePath : resolve(ctx.configDir, filePath);
|
|
57
|
+
try {
|
|
58
|
+
entries.set(name, Redacted.make(readFileSync(fullPath, "utf-8").trim(), { label: name }));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
errors.push({
|
|
61
|
+
context: `${label}.${name}`,
|
|
62
|
+
message: error instanceof Error ? error.message : String(error)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
entries,
|
|
68
|
+
errors
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Resolve every group a scope references, later groups winning.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Last-write-wins across groups matches how every other referenced section
|
|
76
|
+
* composes. A reference with no matching definition is skipped rather than
|
|
77
|
+
* reported — `danglingReferences` owns cross-reference checking, and duplicating
|
|
78
|
+
* it here would report one mistake twice with less context.
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
const resolveGroups = (refs, definitions, ctx) => {
|
|
83
|
+
const entries = /* @__PURE__ */ new Map();
|
|
84
|
+
const errors = [];
|
|
85
|
+
for (const ref of refs) {
|
|
86
|
+
const group = definitions[ref];
|
|
87
|
+
if (group === void 0) continue;
|
|
88
|
+
const outcome = resolveOne(group, ref, ctx);
|
|
89
|
+
for (const [name, value] of outcome.entries) entries.set(name, value);
|
|
90
|
+
errors.push(...outcome.errors);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
entries,
|
|
94
|
+
errors
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* The names a set of groups declares, without resolving any value.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* `cleanup` needs to know *which* names a config declares so it can delete the
|
|
102
|
+
* rest. It has no use for the values, and reading them would be actively wrong:
|
|
103
|
+
* a `{ file }` group would open every secret file — on the path whose whole job
|
|
104
|
+
* is deletion — and a missing file would report an error about a resource
|
|
105
|
+
* cleanup was only ever going to keep.
|
|
106
|
+
*
|
|
107
|
+
* All three kinds carry their names as keys, so the names are available without
|
|
108
|
+
* touching a filesystem or a credential.
|
|
109
|
+
*
|
|
110
|
+
* @public
|
|
111
|
+
*/
|
|
112
|
+
const declaredNames = (refs, definitions) => {
|
|
113
|
+
const names = /* @__PURE__ */ new Set();
|
|
114
|
+
for (const ref of refs) {
|
|
115
|
+
const group = definitions[ref];
|
|
116
|
+
if (group === void 0) continue;
|
|
117
|
+
const entries = "value" in group ? group.value : "resolved" in group ? group.resolved : group.file;
|
|
118
|
+
for (const name of Object.keys(entries)) names.add(name);
|
|
119
|
+
}
|
|
120
|
+
return names;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Read something, keeping the failure's own words if it fails.
|
|
124
|
+
*
|
|
125
|
+
* @remarks
|
|
126
|
+
* The alternative — `Effect.orElseSucceed(() => Option.none())` — discards a
|
|
127
|
+
* structured `GitHubError` and leaves the call site inventing a generic string.
|
|
128
|
+
* A real run showed why that matters: a 403 from a mis-scoped token, a 404 for
|
|
129
|
+
* a deleted repository and a network failure all printed "could not read
|
|
130
|
+
* current state", which tells a user nothing about what to do. `GitHubError`
|
|
131
|
+
* already carries `kind`, `status` and `reason`, and its `message` renders
|
|
132
|
+
* them; this keeps that instead of throwing it away.
|
|
133
|
+
*
|
|
134
|
+
* @public
|
|
135
|
+
*/
|
|
136
|
+
const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed: error.message ?? String(error) })));
|
|
137
|
+
/**
|
|
138
|
+
* Wrap an operation so a failure becomes a reportable error rather than a
|
|
139
|
+
* failed run.
|
|
140
|
+
*
|
|
141
|
+
* @public
|
|
142
|
+
*/
|
|
143
|
+
const capture = (context, effect) => effect.pipe(Effect.as(void 0), Effect.catch((error) => Effect.succeed({
|
|
144
|
+
context,
|
|
145
|
+
message: error.message ?? String(error)
|
|
146
|
+
})));
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
export { capture, declaredNames, read, resolveGroups };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { buildRulesetPayload } from "../../schemas/ruleset.js";
|
|
2
|
+
import { SyncLogger } from "../../services/SyncLogger.js";
|
|
3
|
+
import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
4
|
+
import { RepoCache } from "../../store/RepoCache.js";
|
|
5
|
+
import { capture, read } from "./resource.js";
|
|
6
|
+
import { fingerprint } from "../../lib/fingerprint.js";
|
|
7
|
+
import { decide, isDrift, needsApply } from "../decide.js";
|
|
8
|
+
import { Effect, Option, Redacted } from "effect";
|
|
9
|
+
import { Ruleset } from "@effected/github";
|
|
10
|
+
|
|
11
|
+
//#region src/sync/phases/rulesets.ts
|
|
12
|
+
const KIND = "ruleset";
|
|
13
|
+
/** Stands in for a ruleset that is not on the repository at all. */
|
|
14
|
+
const ABSENT = " absent";
|
|
15
|
+
/**
|
|
16
|
+
* Substitute `{ resolved }` references with credential values, in place.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* A ruleset can carry `{ resolved: "LABEL" }` wherever a numeric id is wanted —
|
|
20
|
+
* an integration id on a status check, a repository id on a required workflow.
|
|
21
|
+
* The substitution walks the decoded ruleset because the references can appear
|
|
22
|
+
* at any depth, and the shape is a discriminated union rather than a flat
|
|
23
|
+
* record.
|
|
24
|
+
*
|
|
25
|
+
* Values come from `ctx.credentials` and are numeric ids, not secrets. They are
|
|
26
|
+
* unwrapped here because they end up in the API payload as numbers; nothing
|
|
27
|
+
* secret should be reachable this way, and a config that puts one there has
|
|
28
|
+
* mis-declared it.
|
|
29
|
+
*/
|
|
30
|
+
const substituteResolved = (node, ctx, missing) => {
|
|
31
|
+
if (Array.isArray(node)) return node.map((item) => substituteResolved(item, ctx, missing));
|
|
32
|
+
if (typeof node !== "object" || node === null) return node;
|
|
33
|
+
const record = node;
|
|
34
|
+
const label = record.resolved;
|
|
35
|
+
if (typeof label === "string" && Object.keys(record).length === 1) {
|
|
36
|
+
const value = ctx.credentials.get(label);
|
|
37
|
+
if (value === void 0) {
|
|
38
|
+
missing.add(label);
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
const text = Redacted.value(value);
|
|
42
|
+
const asNumber = Number(text);
|
|
43
|
+
return text.trim() === "" || Number.isNaN(asNumber) ? text : asNumber;
|
|
44
|
+
}
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [key, child] of Object.entries(record)) out[key] = substituteResolved(child, ctx, missing);
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Repository rulesets.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Drift here is **presence-level**, not value-level, and the reason is worth
|
|
54
|
+
* stating because it differs from both neighbours. `GET /rulesets` returns
|
|
55
|
+
* *summaries* — name, id, source type — not the rules themselves; the full
|
|
56
|
+
* configuration needs `GET /rulesets/{id}`, one request per ruleset per
|
|
57
|
+
* repository. So a deleted ruleset is detected and restored, and one edited in
|
|
58
|
+
* the UI is not.
|
|
59
|
+
*
|
|
60
|
+
* That is a cost decision rather than an API limit, unlike `secrets` where no
|
|
61
|
+
* value exists to read. Lifting it means a read per ruleset; worth doing if
|
|
62
|
+
* ruleset drift turns out to matter in practice, and cheap to change here since
|
|
63
|
+
* only `livePrint` would move.
|
|
64
|
+
*
|
|
65
|
+
* **A renamed ruleset creates a second one rather than renaming the first.**
|
|
66
|
+
* GitHub identifies a ruleset by a numeric id assigned at creation, and the
|
|
67
|
+
* config has only a name — so `syncRuleset` matches on name and cannot tell a
|
|
68
|
+
* rename from a new ruleset. That is v3's behaviour, ported deliberately; the
|
|
69
|
+
* `cleanup` phase is what removes the orphan. Fixing it properly needs the
|
|
70
|
+
* applied-state table to remember the id, which is a design change rather than
|
|
71
|
+
* a port.
|
|
72
|
+
*
|
|
73
|
+
* @public
|
|
74
|
+
*/
|
|
75
|
+
const rulesetsPhase = Effect.gen(function* () {
|
|
76
|
+
const rulesets = yield* Ruleset;
|
|
77
|
+
const cache = yield* RepoCache;
|
|
78
|
+
const applied = yield* AppliedState;
|
|
79
|
+
const logger = yield* SyncLogger;
|
|
80
|
+
/** Resolve `{ team }` bypass actors to numeric ids, through the cache. */
|
|
81
|
+
const resolveBypassActors = (ctx, ruleset) => Effect.gen(function* () {
|
|
82
|
+
const actors = ruleset.bypass_actors;
|
|
83
|
+
if (actors === void 0) return {
|
|
84
|
+
ruleset,
|
|
85
|
+
errors: []
|
|
86
|
+
};
|
|
87
|
+
const errors = [];
|
|
88
|
+
const resolved = [];
|
|
89
|
+
for (const actor of actors) {
|
|
90
|
+
const id = actor.actor_id;
|
|
91
|
+
if (typeof id === "object" && id !== null && "resolved" in id) {
|
|
92
|
+
const slug = String(id.resolved);
|
|
93
|
+
const outcome = yield* read(cache.teamId(ctx.owner, slug, rulesets.teamId(slug)));
|
|
94
|
+
if ("failed" in outcome) {
|
|
95
|
+
errors.push({
|
|
96
|
+
context: `resolve team '${slug}'`,
|
|
97
|
+
message: outcome.failed
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
resolved.push({
|
|
102
|
+
...actor,
|
|
103
|
+
actor_id: outcome.value
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
resolved.push(actor);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
ruleset: {
|
|
111
|
+
...ruleset,
|
|
112
|
+
bypass_actors: resolved
|
|
113
|
+
},
|
|
114
|
+
errors
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
const run = (ctx) => Effect.gen(function* () {
|
|
118
|
+
const refs = ctx.config.groups[ctx.group]?.rulesets ?? [];
|
|
119
|
+
if (refs.length === 0) return {
|
|
120
|
+
changes: [],
|
|
121
|
+
errors: []
|
|
122
|
+
};
|
|
123
|
+
const changes = [];
|
|
124
|
+
const errors = [];
|
|
125
|
+
const listing = yield* read(rulesets.list());
|
|
126
|
+
if ("failed" in listing) return {
|
|
127
|
+
changes,
|
|
128
|
+
errors: [{
|
|
129
|
+
context: "list rulesets",
|
|
130
|
+
message: listing.failed
|
|
131
|
+
}]
|
|
132
|
+
};
|
|
133
|
+
const live = new Map(listing.value.filter((entry) => entry.source_type !== "Organization").map((entry) => [entry.name, entry]));
|
|
134
|
+
const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
|
|
135
|
+
for (const ref of refs) {
|
|
136
|
+
const configured = ctx.config.rulesets[ref];
|
|
137
|
+
if (configured === void 0) continue;
|
|
138
|
+
const missing = /* @__PURE__ */ new Set();
|
|
139
|
+
const substituted = substituteResolved(configured, ctx, missing);
|
|
140
|
+
for (const label of missing) errors.push({
|
|
141
|
+
context: `ruleset ${configured.name}`,
|
|
142
|
+
message: `credential label '${label}' is not defined in the active profile's [resolve] section`
|
|
143
|
+
});
|
|
144
|
+
const withActors = yield* resolveBypassActors(ctx, substituted);
|
|
145
|
+
errors.push(...withActors.errors);
|
|
146
|
+
const payload = buildRulesetPayload(withActors.ruleset);
|
|
147
|
+
const desiredPrint = fingerprint(payload);
|
|
148
|
+
const baseline = Option.map(lookup(baselines, KIND, payload.name), (record) => record.fingerprint);
|
|
149
|
+
const livePrint = live.has(payload.name) ? Option.getOrElse(baseline, () => fingerprint(ABSENT)) : fingerprint(ABSENT);
|
|
150
|
+
const decision = decide(desiredPrint, livePrint, baseline);
|
|
151
|
+
if (isDrift(decision)) yield* logger.driftDetected(KIND, payload.name, decision);
|
|
152
|
+
if (!needsApply(decision)) continue;
|
|
153
|
+
if (!ctx.dryRun) {
|
|
154
|
+
const failure = yield* capture(`ruleset ${payload.name}`, rulesets.upsert(payload));
|
|
155
|
+
if (failure !== void 0) {
|
|
156
|
+
errors.push(failure);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
yield* applied.record({
|
|
160
|
+
repo: ctx.slug,
|
|
161
|
+
kind: KIND,
|
|
162
|
+
name: payload.name
|
|
163
|
+
}, desiredPrint, ctx.runId).pipe(Effect.ignore);
|
|
164
|
+
}
|
|
165
|
+
yield* logger.syncOperation("sync", KIND, payload.name);
|
|
166
|
+
changes.push({
|
|
167
|
+
repo: ctx.slug,
|
|
168
|
+
kind: KIND,
|
|
169
|
+
name: payload.name,
|
|
170
|
+
action: isDrift(decision) ? "drift-overwritten" : "updated"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
changes,
|
|
175
|
+
errors
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
name: "rulesets",
|
|
180
|
+
appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.rulesets?.length ?? 0) > 0,
|
|
181
|
+
run
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
//#endregion
|
|
186
|
+
export { rulesetsPhase };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { SyncLogger } from "../../services/SyncLogger.js";
|
|
2
|
+
import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
3
|
+
import { capture, resolveGroups } from "./resource.js";
|
|
4
|
+
import { fingerprint } from "../../lib/fingerprint.js";
|
|
5
|
+
import { decide, isDrift, needsApply } from "../decide.js";
|
|
6
|
+
import { Effect, Option, Redacted } from "effect";
|
|
7
|
+
import { RepositorySecret } from "@effected/github";
|
|
8
|
+
|
|
9
|
+
//#region src/sync/phases/secrets.ts
|
|
10
|
+
const KIND = "secret";
|
|
11
|
+
/** The three repository-level stores, each with its own keys and its own paths. */
|
|
12
|
+
const SCOPES = [
|
|
13
|
+
"actions",
|
|
14
|
+
"dependabot",
|
|
15
|
+
"codespaces"
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Stands in for a secret that is not on the repository at all.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Absence needs a fingerprint distinct from every real value, so a secret
|
|
22
|
+
* someone deleted out of band compares unequal to what we last applied and gets
|
|
23
|
+
* restored rather than silently matching.
|
|
24
|
+
*/
|
|
25
|
+
const ABSENT = "\0absent";
|
|
26
|
+
/** A scoped secret's drift key. Environment secrets are namespaced by environment. */
|
|
27
|
+
const resourceName = (scope, name) => `${scope}/${name}`;
|
|
28
|
+
/**
|
|
29
|
+
* Repository and environment secrets.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* **Drift here is presence-only, and unlike `variables` that is not a
|
|
33
|
+
* limitation we can lift.** GitHub never returns a secret's value from any
|
|
34
|
+
* endpoint — that is the point of a secret store — so a secret edited in the UI
|
|
35
|
+
* is indistinguishable from one we wrote ourselves. What is detectable is a
|
|
36
|
+
* secret that has been *deleted*: it vanishes from the listing, compares
|
|
37
|
+
* unequal to the baseline, and is restored.
|
|
38
|
+
*
|
|
39
|
+
* That makes the live comparison here deliberately different from the one in
|
|
40
|
+
* `variables`: a present secret is taken to match the baseline, because there
|
|
41
|
+
* is nothing else to compare it against. `variables` used to do the same thing
|
|
42
|
+
* and it was a bug there — the value was available and being discarded. It is
|
|
43
|
+
* not available here. **Do not "fix" this to match.**
|
|
44
|
+
*
|
|
45
|
+
* Values stay {@link Redacted.Redacted} the whole way through. `Redacted.value`
|
|
46
|
+
* appears exactly once per write, at the call that encrypts, and the sealed box
|
|
47
|
+
* is what crosses the wire — the plaintext never reaches a log line, an error,
|
|
48
|
+
* or the journal, which records only names.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
const secretsPhase = Effect.gen(function* () {
|
|
53
|
+
const secrets = yield* RepositorySecret;
|
|
54
|
+
const applied = yield* AppliedState;
|
|
55
|
+
const logger = yield* SyncLogger;
|
|
56
|
+
const run = (ctx) => Effect.gen(function* () {
|
|
57
|
+
const scopes = ctx.config.groups[ctx.group]?.secrets;
|
|
58
|
+
if (scopes === void 0) return {
|
|
59
|
+
changes: [],
|
|
60
|
+
errors: []
|
|
61
|
+
};
|
|
62
|
+
const changes = [];
|
|
63
|
+
const errors = [];
|
|
64
|
+
const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
|
|
65
|
+
/**
|
|
66
|
+
* Sync one store.
|
|
67
|
+
*
|
|
68
|
+
* @param environment - absent for a repository-level scope, present for
|
|
69
|
+
* an environment's own secret store.
|
|
70
|
+
*/
|
|
71
|
+
const syncStore = (entries, scope, environment) => Effect.gen(function* () {
|
|
72
|
+
if (entries.size === 0) return;
|
|
73
|
+
const label = environment === void 0 ? scope : `env:${environment}`;
|
|
74
|
+
const live = yield* (environment === void 0 ? secrets.list(scope) : secrets.listForEnvironment(environment)).pipe(Effect.map((list) => Option.some(new Set(list.map((entry) => entry.name)))), Effect.orElseSucceed(() => Option.none()));
|
|
75
|
+
if (Option.isNone(live)) {
|
|
76
|
+
errors.push({
|
|
77
|
+
context: `list secrets (${label})`,
|
|
78
|
+
message: "could not read current secrets"
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const [name, value] of entries) {
|
|
83
|
+
const resource = resourceName(label, name);
|
|
84
|
+
const desiredPrint = fingerprint(Redacted.value(value));
|
|
85
|
+
const baseline = Option.map(lookup(baselines, KIND, resource), (record) => record.fingerprint);
|
|
86
|
+
const livePrint = live.value.has(name) ? Option.getOrElse(baseline, () => fingerprint(ABSENT)) : fingerprint(ABSENT);
|
|
87
|
+
const decision = decide(desiredPrint, livePrint, baseline);
|
|
88
|
+
if (isDrift(decision)) yield* logger.driftDetected(KIND, resource, decision);
|
|
89
|
+
if (!needsApply(decision)) continue;
|
|
90
|
+
if (!ctx.dryRun) {
|
|
91
|
+
const failure = yield* capture(`secret ${name} (${label})`, environment === void 0 ? secrets.set(name, value, scope) : secrets.setForEnvironment(environment, name, value));
|
|
92
|
+
if (failure !== void 0) {
|
|
93
|
+
errors.push(failure);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
yield* applied.record({
|
|
97
|
+
repo: ctx.slug,
|
|
98
|
+
kind: KIND,
|
|
99
|
+
name: resource
|
|
100
|
+
}, desiredPrint, ctx.runId).pipe(Effect.ignore);
|
|
101
|
+
}
|
|
102
|
+
yield* logger.syncOperation("sync", KIND, name, `(${label})`);
|
|
103
|
+
changes.push({
|
|
104
|
+
repo: ctx.slug,
|
|
105
|
+
kind: KIND,
|
|
106
|
+
name: resource,
|
|
107
|
+
action: isDrift(decision) ? "drift-overwritten" : "updated"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
for (const scope of SCOPES) {
|
|
112
|
+
const resolved = resolveGroups(scopes[scope] ?? [], ctx.config.secrets, ctx);
|
|
113
|
+
errors.push(...resolved.errors);
|
|
114
|
+
yield* syncStore(resolved.entries, scope, void 0);
|
|
115
|
+
}
|
|
116
|
+
for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
|
|
117
|
+
const resolved = resolveGroups(refs, ctx.config.secrets, ctx);
|
|
118
|
+
errors.push(...resolved.errors);
|
|
119
|
+
yield* syncStore(resolved.entries, "actions", environment);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
changes,
|
|
123
|
+
errors
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
name: "secrets",
|
|
128
|
+
appliesTo: (ctx) => {
|
|
129
|
+
const scopes = ctx.config.groups[ctx.group]?.secrets;
|
|
130
|
+
if (scopes === void 0) return false;
|
|
131
|
+
return SCOPES.some((scope) => (scopes[scope]?.length ?? 0) > 0) || Object.keys(scopes.environments ?? {}).length > 0;
|
|
132
|
+
},
|
|
133
|
+
run
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
export { secretsPhase };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { SyncLogger } from "../../services/SyncLogger.js";
|
|
2
|
+
import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
3
|
+
import { fingerprint } from "../../lib/fingerprint.js";
|
|
4
|
+
import { decide, isDrift, needsApply } from "../decide.js";
|
|
5
|
+
import { Effect, Option } from "effect";
|
|
6
|
+
import { RepositorySecurity } from "@effected/github";
|
|
7
|
+
|
|
8
|
+
//#region src/sync/phases/security.ts
|
|
9
|
+
const KIND = "security";
|
|
10
|
+
/** The three toggles, and the merged config field each reads. */
|
|
11
|
+
const TOGGLES = [
|
|
12
|
+
"vulnerability_alerts",
|
|
13
|
+
"automated_security_fixes",
|
|
14
|
+
"private_vulnerability_reporting"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Merge the security groups a repository's group references.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Later groups win, matching how every other `[settings.*]`-style section
|
|
21
|
+
* composes. An absent field is not `false` — it means "leave alone", which is
|
|
22
|
+
* why this returns a partial record rather than defaulting.
|
|
23
|
+
*/
|
|
24
|
+
const mergeSecurity = (ctx) => {
|
|
25
|
+
const refs = ctx.config.groups[ctx.group]?.security ?? [];
|
|
26
|
+
const merged = {};
|
|
27
|
+
for (const ref of refs) {
|
|
28
|
+
const group = ctx.config.security[ref];
|
|
29
|
+
if (group === void 0) continue;
|
|
30
|
+
for (const toggle of TOGGLES) {
|
|
31
|
+
const value = group[toggle];
|
|
32
|
+
if (value !== void 0) merged[toggle] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return merged;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* GitHub rejects automated fixes without alerts.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Caught before any write rather than after the first one fails: applying
|
|
42
|
+
* `automated_security_fixes` first would leave the repository half-configured
|
|
43
|
+
* and the error attributed to the wrong toggle.
|
|
44
|
+
*/
|
|
45
|
+
const contradicts = (merged) => merged.automated_security_fixes === true && merged.vulnerability_alerts === false;
|
|
46
|
+
/**
|
|
47
|
+
* Repository security toggles: vulnerability alerts, automated fixes, private
|
|
48
|
+
* vulnerability reporting.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* Each toggle is a separate resource for drift purposes, so someone flipping
|
|
52
|
+
* private vulnerability reporting in the UI is reported as that, not as
|
|
53
|
+
* "security changed".
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
const securityPhase = Effect.gen(function* () {
|
|
58
|
+
const security = yield* RepositorySecurity;
|
|
59
|
+
const applied = yield* AppliedState;
|
|
60
|
+
const logger = yield* SyncLogger;
|
|
61
|
+
const read = (toggle) => {
|
|
62
|
+
return (toggle === "vulnerability_alerts" ? security.vulnerabilityAlerts() : toggle === "automated_security_fixes" ? security.automatedSecurityFixes() : security.privateVulnerabilityReporting()).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
|
|
63
|
+
};
|
|
64
|
+
const write = (toggle, value) => {
|
|
65
|
+
return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
|
|
66
|
+
};
|
|
67
|
+
const run = (ctx) => Effect.gen(function* () {
|
|
68
|
+
const merged = mergeSecurity(ctx);
|
|
69
|
+
if (contradicts(merged)) return {
|
|
70
|
+
changes: [],
|
|
71
|
+
errors: [{
|
|
72
|
+
context: "security merge",
|
|
73
|
+
message: "automated_security_fixes = true requires vulnerability_alerts to be enabled (or omitted); skipping security sync"
|
|
74
|
+
}]
|
|
75
|
+
};
|
|
76
|
+
const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
|
|
77
|
+
const changes = [];
|
|
78
|
+
const errors = [];
|
|
79
|
+
for (const toggle of TOGGLES) {
|
|
80
|
+
const desired = merged[toggle];
|
|
81
|
+
if (desired === void 0) continue;
|
|
82
|
+
const live = yield* read(toggle);
|
|
83
|
+
if (Option.isNone(live)) {
|
|
84
|
+
errors.push({
|
|
85
|
+
context: `get ${toggle}`,
|
|
86
|
+
message: "could not read current state"
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const decision = decide(fingerprint(desired), fingerprint(live.value), Option.map(lookup(baselines, KIND, toggle), (record) => record.fingerprint));
|
|
91
|
+
if (isDrift(decision)) yield* logger.driftDetected(KIND, toggle, decision);
|
|
92
|
+
if (!needsApply(decision)) continue;
|
|
93
|
+
if (!ctx.dryRun) {
|
|
94
|
+
const failure = yield* write(toggle, desired);
|
|
95
|
+
if (Option.isSome(failure)) {
|
|
96
|
+
errors.push({
|
|
97
|
+
context: toggle,
|
|
98
|
+
message: failure.value
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
yield* applied.record({
|
|
103
|
+
repo: ctx.slug,
|
|
104
|
+
kind: KIND,
|
|
105
|
+
name: toggle
|
|
106
|
+
}, fingerprint(desired), ctx.runId).pipe(Effect.ignore);
|
|
107
|
+
}
|
|
108
|
+
yield* logger.syncOperation("sync", KIND, toggle, desired ? "enable" : "disable");
|
|
109
|
+
changes.push({
|
|
110
|
+
repo: ctx.slug,
|
|
111
|
+
kind: KIND,
|
|
112
|
+
name: toggle,
|
|
113
|
+
action: isDrift(decision) ? "drift-overwritten" : "updated"
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
changes,
|
|
118
|
+
errors
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
name: "security",
|
|
123
|
+
appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.security?.length ?? 0) > 0,
|
|
124
|
+
run
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
//#endregion
|
|
129
|
+
export { securityPhase };
|