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.
Files changed (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. package/services/SyncEngine.js +0 -580
package/sync/decide.js ADDED
@@ -0,0 +1,56 @@
1
+ //#region src/sync/decide.ts
2
+ /**
3
+ * Decides what to do about one resource from three fingerprints.
4
+ *
5
+ * @remarks
6
+ * Pure and total — no I/O, no failure mode. All three inputs are
7
+ * {@link fingerprint} outputs over the same resource.
8
+ *
9
+ * The distinction that justifies persisting anything: **`desired` vs `live`
10
+ * tells you something is wrong; `live` vs `applied` tells you who did it.**
11
+ * Without the stored `applied` fingerprint the two are indistinguishable, and
12
+ * every divergence looks like a config change.
13
+ *
14
+ * `Drift` is reported even when `needsApply` is `false` — that is the case where
15
+ * someone edited the repository in the GitHub UI to a value that happens to
16
+ * match the config. Nothing needs writing, but a human still changed something
17
+ * out of band and the journal should say so.
18
+ *
19
+ * A missing `applied` row yields `FirstSync` rather than `Drift`: with no
20
+ * baseline there is no evidence anyone changed anything, and claiming drift on
21
+ * first contact would flag every pre-existing repository.
22
+ *
23
+ * @param desired - fingerprint of the resolved config for this resource
24
+ * @param live - fingerprint of what GitHub currently reports
25
+ * @param applied - fingerprint reposets last wrote, if it has ever written one
26
+ *
27
+ * @public
28
+ */
29
+ const decide = (desired, live, applied) => {
30
+ if (applied._tag === "None") return {
31
+ _tag: "FirstSync",
32
+ needsApply: desired !== live
33
+ };
34
+ if (applied.value === live) return desired === live ? { _tag: "InSync" } : { _tag: "ConfigChanged" };
35
+ return {
36
+ _tag: "Drift",
37
+ applied: applied.value,
38
+ live,
39
+ needsApply: desired !== live
40
+ };
41
+ };
42
+ /**
43
+ * Whether a decision requires writing to GitHub.
44
+ *
45
+ * @public
46
+ */
47
+ const needsApply = (decision) => decision._tag === "ConfigChanged" || (decision._tag === "FirstSync" || decision._tag === "Drift") && decision.needsApply;
48
+ /**
49
+ * Whether a decision represents an out-of-band change worth reporting.
50
+ *
51
+ * @public
52
+ */
53
+ const isDrift = (decision) => decision._tag === "Drift";
54
+
55
+ //#endregion
56
+ export { decide, isDrift, needsApply };
package/sync/phase.js ADDED
@@ -0,0 +1,55 @@
1
+ import { Effect } from "effect";
2
+
3
+ //#region src/sync/phase.ts
4
+ /** A phase that touched nothing and failed at nothing. */
5
+ const emptyResult = {
6
+ changes: [],
7
+ errors: []
8
+ };
9
+ /**
10
+ * The phases a run can execute, in the order they must run.
11
+ *
12
+ * @remarks
13
+ * Order is not cosmetic. Environments exist before the secrets and variables
14
+ * scoped to them, and settings land before the security toggles that read the
15
+ * repository's state. The array *is* the contract.
16
+ *
17
+ * @public
18
+ */
19
+ const PHASE_NAMES = [
20
+ "settings",
21
+ "security",
22
+ "code-scanning",
23
+ "environments",
24
+ "secrets",
25
+ "variables",
26
+ "rulesets",
27
+ "cleanup"
28
+ ];
29
+ /**
30
+ * Select the phases a run should execute.
31
+ *
32
+ * @remarks
33
+ * `only` wins over `skip` when both name the same phase — an explicit inclusion
34
+ * is a stronger statement than an exclusion, and the alternative is silently
35
+ * running nothing.
36
+ *
37
+ * Selection preserves {@link PHASE_NAMES} order regardless of the order the
38
+ * flags were given, because the order is a correctness property rather than a
39
+ * preference.
40
+ *
41
+ * @public
42
+ */
43
+ const selectPhases = (phases, options) => phases.filter((phase) => options.only !== void 0 && options.only.size > 0 ? options.only.has(phase.name) : !(options.skip?.has(phase.name) ?? false));
44
+ /**
45
+ * Merge results from several phases.
46
+ *
47
+ * @public
48
+ */
49
+ const mergeResults = (results) => ({
50
+ changes: results.flatMap((r) => r.changes),
51
+ errors: results.flatMap((r) => r.errors)
52
+ });
53
+
54
+ //#endregion
55
+ export { PHASE_NAMES, emptyResult, mergeResults, selectPhases };
@@ -0,0 +1,220 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState } from "../../store/AppliedState.js";
3
+ import { emptyResult } from "../phase.js";
4
+ import { capture, declaredNames, read } from "./resource.js";
5
+ import { Effect } from "effect";
6
+ import { DeploymentEnvironment, RepositorySecret, RepositoryVariable, Ruleset } from "@effected/github";
7
+
8
+ //#region src/sync/phases/cleanup.ts
9
+ /** The secret stores that live on the repository rather than an environment. */
10
+ const SECRET_SCOPES = [
11
+ "actions",
12
+ "dependabot",
13
+ "codespaces"
14
+ ];
15
+ /**
16
+ * Which live resources a scope permits deleting.
17
+ *
18
+ * @remarks
19
+ * Total over the three-way union, and the reason it is a separate pure function
20
+ * is that it is the only place the policy is decided — every sweep below routes
21
+ * through it, so `preserve` cannot be honoured in one scope and forgotten in
22
+ * another.
23
+ */
24
+ const undeclared = (live, declared, scope) => {
25
+ if (scope === false) return [];
26
+ const preserved = scope === true ? /* @__PURE__ */ new Set() : new Set(scope.preserve);
27
+ return live.filter((entry) => !declared.has(entry.name) && !preserved.has(entry.name));
28
+ };
29
+ /** Whether any scope in a group's cleanup config is turned on. */
30
+ const anyEnabled = (cleanup) => {
31
+ if (cleanup === void 0) return false;
32
+ return [
33
+ cleanup.secrets.actions,
34
+ cleanup.secrets.dependabot,
35
+ cleanup.secrets.codespaces,
36
+ cleanup.secrets.environments,
37
+ cleanup.variables.actions,
38
+ cleanup.variables.environments,
39
+ cleanup.rulesets,
40
+ cleanup.environments
41
+ ].some((scope) => scope !== false);
42
+ };
43
+ /**
44
+ * Delete undeclared resources.
45
+ *
46
+ * @remarks
47
+ * This phase is the only one that removes anything, and it runs last because
48
+ * every other phase's writes define what "declared" means.
49
+ *
50
+ * **An enabled scope with nothing declared deletes everything in that scope.**
51
+ * That is the literal reading of "delete what the config does not declare", and
52
+ * it is deliberate rather than an oversight: a group that turns on
53
+ * `cleanup.secrets.actions` and references no secret groups is saying this
54
+ * repository should have no Actions secrets, which is a legitimate thing to
55
+ * say and the only way to say it. It is also the most destructive thing in this
56
+ * program, so every deletion is named in the output and `--dry-run` lists them
57
+ * without touching anything.
58
+ *
59
+ * **An organization's rulesets are never deleted.** `listRulesets` returns
60
+ * rulesets inherited from the org alongside the repository's own, and they are
61
+ * not this repository's to remove — deleting one would change policy for every
62
+ * repository in the organization. They are filtered out before anything is
63
+ * considered undeclared, and a test pins it.
64
+ *
65
+ * **Every deletion forgets its applied-state row.** Leaving the row behind
66
+ * would make the next run compare a recreated resource against a fingerprint
67
+ * from before the deletion and report drift that nobody caused.
68
+ *
69
+ * Nothing here reads a secret value. Deciding what to delete needs names, which
70
+ * {@link declaredNames} takes from the config without opening a file.
71
+ *
72
+ * @public
73
+ */
74
+ const cleanupPhase = Effect.gen(function* () {
75
+ const secrets = yield* RepositorySecret;
76
+ const variables = yield* RepositoryVariable;
77
+ const rulesets = yield* Ruleset;
78
+ const environments = yield* DeploymentEnvironment;
79
+ const applied = yield* AppliedState;
80
+ const logger = yield* SyncLogger;
81
+ /**
82
+ * Sweep one scope.
83
+ *
84
+ * @remarks
85
+ * A disabled scope returns before the listing, so cleanup that is off costs
86
+ * no round trip — the same reason `appliesTo` exists at the phase level.
87
+ */
88
+ const sweep = (ctx, options) => Effect.gen(function* () {
89
+ if (options.scope === false) return emptyResult;
90
+ const listing = yield* read(options.live());
91
+ if ("failed" in listing) return {
92
+ changes: [],
93
+ errors: [{
94
+ context: `list ${options.resource}s`,
95
+ message: listing.failed
96
+ }]
97
+ };
98
+ const targets = undeclared(listing.value, options.declared, options.scope);
99
+ if (targets.length === 0) return emptyResult;
100
+ const changes = [];
101
+ const errors = [];
102
+ const deleted = [];
103
+ for (const target of targets) {
104
+ if (!ctx.dryRun) {
105
+ const failure = yield* capture(`delete ${options.resource} ${target.name}`, target.remove());
106
+ if (failure !== void 0) {
107
+ errors.push(failure);
108
+ continue;
109
+ }
110
+ yield* applied.forget({
111
+ repo: ctx.slug,
112
+ kind: options.kind,
113
+ name: options.appliedName(target.name)
114
+ }).pipe(Effect.ignore);
115
+ }
116
+ deleted.push(target.name);
117
+ changes.push({
118
+ repo: ctx.slug,
119
+ kind: options.kind,
120
+ name: options.appliedName(target.name),
121
+ action: "deleted"
122
+ });
123
+ }
124
+ if (deleted.length > 0) yield* logger.cleanupSummary(options.resource, deleted.length, deleted);
125
+ return {
126
+ changes,
127
+ errors
128
+ };
129
+ });
130
+ const run = (ctx) => Effect.gen(function* () {
131
+ const group = ctx.config.groups[ctx.group];
132
+ const cleanup = group?.cleanup;
133
+ if (group === void 0 || cleanup === void 0) return emptyResult;
134
+ const results = [];
135
+ for (const scope of SECRET_SCOPES) results.push(yield* sweep(ctx, {
136
+ scope: cleanup.secrets[scope],
137
+ kind: "secret",
138
+ resource: `${scope} secret`,
139
+ declared: declaredNames(group.secrets?.[scope] ?? [], ctx.config.secrets),
140
+ live: () => secrets.list(scope).pipe(Effect.map((list) => list.map((entry) => ({
141
+ name: entry.name,
142
+ remove: () => secrets.delete(entry.name, scope)
143
+ })))),
144
+ appliedName: (name) => `${scope}/${name}`
145
+ }));
146
+ for (const [environment, refs] of Object.entries(group.secrets?.environments ?? {})) results.push(yield* sweep(ctx, {
147
+ scope: cleanup.secrets.environments,
148
+ kind: "secret",
149
+ resource: `${environment} environment secret`,
150
+ declared: declaredNames(refs, ctx.config.secrets),
151
+ live: () => secrets.listForEnvironment(environment).pipe(Effect.map((list) => list.map((entry) => ({
152
+ name: entry.name,
153
+ remove: () => secrets.deleteForEnvironment(environment, entry.name)
154
+ })))),
155
+ appliedName: (name) => `env:${environment}/${name}`
156
+ }));
157
+ results.push(yield* sweep(ctx, {
158
+ scope: cleanup.variables.actions,
159
+ kind: "variable",
160
+ resource: "variable",
161
+ declared: declaredNames(group.variables?.actions ?? [], ctx.config.variables),
162
+ live: () => variables.list().pipe(Effect.map((list) => list.map((entry) => ({
163
+ name: entry.name,
164
+ remove: () => variables.delete(entry.name)
165
+ })))),
166
+ appliedName: (name) => name
167
+ }));
168
+ for (const [environment, refs] of Object.entries(group.variables?.environments ?? {})) results.push(yield* sweep(ctx, {
169
+ scope: cleanup.variables.environments,
170
+ kind: "variable",
171
+ resource: `${environment} environment variable`,
172
+ declared: declaredNames(refs, ctx.config.variables),
173
+ live: () => variables.listForEnvironment(environment).pipe(Effect.map((list) => list.map((entry) => ({
174
+ name: entry.name,
175
+ remove: () => variables.deleteForEnvironment(environment, entry.name)
176
+ })))),
177
+ appliedName: (name) => `${environment}/${name}`
178
+ }));
179
+ const declaredRulesets = new Set((group.rulesets ?? []).map((ref) => ctx.config.rulesets[ref]?.name).filter((name) => name !== void 0));
180
+ results.push(yield* sweep(ctx, {
181
+ scope: cleanup.rulesets,
182
+ kind: "ruleset",
183
+ resource: "ruleset",
184
+ declared: declaredRulesets,
185
+ live: () => rulesets.list().pipe(Effect.map((list) => list.filter((entry) => entry.source_type !== "Organization").map((entry) => ({
186
+ name: entry.name,
187
+ remove: () => rulesets.delete(entry.id)
188
+ })))),
189
+ appliedName: (name) => name
190
+ }));
191
+ results.push(yield* sweep(ctx, {
192
+ scope: cleanup.environments,
193
+ kind: "environment",
194
+ resource: "environment",
195
+ declared: new Set(group.environments ?? []),
196
+ live: () => environments.list().pipe(Effect.map((list) => list.map((entry) => ({
197
+ name: entry.name,
198
+ remove: () => environments.delete(entry.name)
199
+ })))),
200
+ appliedName: (name) => name
201
+ }));
202
+ return {
203
+ changes: results.flatMap((result) => result.changes),
204
+ errors: results.flatMap((result) => result.errors)
205
+ };
206
+ });
207
+ return {
208
+ name: "cleanup",
209
+ /**
210
+ * `--no-cleanup` declines here rather than inside `run`, so the flag costs
211
+ * nothing and reads as "this phase has nothing to do" — which is what the
212
+ * user asked for by passing it.
213
+ */
214
+ appliesTo: (ctx) => !ctx.noCleanup && anyEnabled(ctx.config.groups[ctx.group]?.cleanup),
215
+ run
216
+ };
217
+ });
218
+
219
+ //#endregion
220
+ export { cleanupPhase };
@@ -0,0 +1,187 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
+ import { RepoCache } from "../../store/RepoCache.js";
4
+ import { read } from "./resource.js";
5
+ import { fingerprint } from "../../lib/fingerprint.js";
6
+ import { decide, isDrift, needsApply } from "../decide.js";
7
+ import { Effect, Option } from "effect";
8
+ import { CodeScanning, WorkflowDispatch } from "@effected/github";
9
+
10
+ //#region src/sync/phases/code-scanning.ts
11
+ /**
12
+ * Stands in for live state this phase cannot read.
13
+ *
14
+ * @remarks
15
+ * `decide` needs three fingerprints, and GitHub exposes no read of these
16
+ * resources comparable to what is written — the PATCH surface and the GET
17
+ * surface differ, and some fields are not on the GET at all. So live is taken
18
+ * to be the last applied value, and this sentinel covers the case where there
19
+ * is no last applied value.
20
+ *
21
+ * It must never equal a real fingerprint. Using the desired fingerprint here
22
+ * instead would make `FirstSync` compute `needsApply: desired !== live` as
23
+ * `false` — and the very first sync of a repository would write nothing at all.
24
+ *
25
+ * The consequence, stated plainly: **this phase reports config change, not
26
+ * drift.** A baseline that differs reads as `ConfigChanged`, never as `Drift`,
27
+ * because there is no independent observation to attribute the difference to.
28
+ */
29
+ const UNREADABLE = "\0unreadable";
30
+ const KIND = "code_scanning";
31
+ /** A repository has one default-setup configuration. */
32
+ const NAME = "default-setup";
33
+ /**
34
+ * GitHub's repository language names mapped to CodeQL default-setup languages.
35
+ *
36
+ * @remarks
37
+ * The two vocabularies differ: `listLanguages` reports `TypeScript`, CodeQL
38
+ * wants `javascript-typescript`, and several source languages collapse onto one
39
+ * analyzer. A language with no mapping is dropped from the detected set —
40
+ * CodeQL cannot analyse it, so it cannot corroborate a configured language.
41
+ */
42
+ const REPO_LANG_TO_CODEQL = {
43
+ JavaScript: "javascript-typescript",
44
+ TypeScript: "javascript-typescript",
45
+ C: "c-cpp",
46
+ "C++": "c-cpp",
47
+ "C#": "csharp",
48
+ Go: "go",
49
+ Java: "java-kotlin",
50
+ Kotlin: "java-kotlin",
51
+ Python: "python",
52
+ Ruby: "ruby",
53
+ Swift: "swift"
54
+ };
55
+ /** Last-write-wins merge of the code scanning groups a repository references. */
56
+ const mergeCodeScanning = (ctx) => {
57
+ const refs = ctx.config.groups[ctx.group]?.code_scanning ?? [];
58
+ const merged = {};
59
+ for (const ref of refs) {
60
+ const group = ctx.config.code_scanning[ref];
61
+ if (group === void 0) continue;
62
+ for (const [key, value] of Object.entries(group)) if (value !== void 0) merged[key] = value;
63
+ }
64
+ return merged;
65
+ };
66
+ /**
67
+ * CodeQL default setup, gated by the languages GitHub actually detects.
68
+ *
69
+ * @remarks
70
+ * Configuring a language the repository does not contain makes GitHub reject
71
+ * the whole request, so the configured list is intersected with the detected
72
+ * one and the difference is reported rather than sent. That read goes through
73
+ * {@link RepoCache}, whose languages entry is deliberately short-lived — a repo
74
+ * that just gained a language should start scanning it within the hour, not the
75
+ * month.
76
+ *
77
+ * `actions` is checked against the repository's **workflow count** rather than
78
+ * against its languages, because GitHub validates it and `listRepoLanguages`
79
+ * cannot see it. Passing it through unconditionally — which this did — answers
80
+ *
81
+ * > One or more languages you selected are not present in the repository.
82
+ *
83
+ * with a 422 on a repository that has no workflows. The check runs only when
84
+ * `actions` is actually configured.
85
+ *
86
+ * Historically it analysed workflow YAML rather
87
+ * than a repository language, so `listLanguages` never reports it and filtering
88
+ * on detection would remove it from every repository.
89
+ *
90
+ * @public
91
+ */
92
+ const codeScanningPhase = Effect.gen(function* () {
93
+ const codeScanning = yield* CodeScanning;
94
+ const workflows = yield* WorkflowDispatch;
95
+ const cache = yield* RepoCache;
96
+ const applied = yield* AppliedState;
97
+ const logger = yield* SyncLogger;
98
+ const run = (ctx) => Effect.gen(function* () {
99
+ const merged = mergeCodeScanning(ctx);
100
+ if (Object.keys(merged).length === 0) return {
101
+ changes: [],
102
+ errors: []
103
+ };
104
+ const errors = [];
105
+ let desired = merged;
106
+ if (merged.languages !== void 0) {
107
+ const detected = yield* cache.repoLanguages(ctx.owner, ctx.repo, codeScanning.languages()).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
108
+ if (Option.isNone(detected)) errors.push({
109
+ context: "list repo languages",
110
+ message: "could not read repository languages"
111
+ });
112
+ const detectedCodeQL = /* @__PURE__ */ new Set();
113
+ for (const language of Option.getOrElse(detected, () => [])) {
114
+ const mapped = REPO_LANG_TO_CODEQL[language];
115
+ if (mapped !== void 0) detectedCodeQL.add(mapped);
116
+ }
117
+ const hasWorkflows = merged.languages.includes("actions") ? yield* read(cache.repoWorkflows(ctx.owner, ctx.repo, Effect.map(workflows.list, (found) => found.filter((workflow) => workflow.path.startsWith(".github/workflows/")).length))) : { value: 0 };
118
+ const filtered = [];
119
+ for (const language of merged.languages) {
120
+ if (language === "actions") {
121
+ if ("failed" in hasWorkflows) errors.push({
122
+ context: "code_scanning",
123
+ message: `could not count workflows: ${hasWorkflows.failed}`
124
+ });
125
+ else if (hasWorkflows.value > 0) filtered.push(language);
126
+ else yield* logger.syncOperation("skip", "code_scanning language", language, "(no workflow files)");
127
+ continue;
128
+ }
129
+ if (detectedCodeQL.has(language)) filtered.push(language);
130
+ else yield* logger.syncOperation("skip", "code_scanning language", language, "(not detected in repository)");
131
+ }
132
+ if (filtered.length === 0) {
133
+ yield* logger.syncOperation("skip", "code_scanning", NAME, "(no configured language is present in this repository)");
134
+ return {
135
+ changes: [],
136
+ errors
137
+ };
138
+ }
139
+ desired = {
140
+ ...merged,
141
+ languages: filtered
142
+ };
143
+ }
144
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
145
+ const baseline = Option.map(lookup(baselines, KIND, NAME), (record) => record.fingerprint);
146
+ const desiredPrint = fingerprint(desired);
147
+ const decision = decide(desiredPrint, Option.getOrElse(baseline, () => UNREADABLE), baseline);
148
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, NAME, decision);
149
+ if (!needsApply(decision)) return {
150
+ changes: [],
151
+ errors
152
+ };
153
+ if (!ctx.dryRun) {
154
+ const failure = yield* codeScanning.configure(desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
155
+ if (Option.isSome(failure)) return {
156
+ changes: [],
157
+ errors: [...errors, {
158
+ context: "code_scanning default setup",
159
+ message: failure.value
160
+ }]
161
+ };
162
+ yield* applied.record({
163
+ repo: ctx.slug,
164
+ kind: KIND,
165
+ name: NAME
166
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
167
+ }
168
+ yield* logger.syncOperation("sync", KIND, desired.state ?? NAME);
169
+ return {
170
+ changes: [{
171
+ repo: ctx.slug,
172
+ kind: KIND,
173
+ name: NAME,
174
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
175
+ }],
176
+ errors
177
+ };
178
+ });
179
+ return {
180
+ name: "code-scanning",
181
+ appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.code_scanning?.length ?? 0) > 0,
182
+ run
183
+ };
184
+ });
185
+
186
+ //#endregion
187
+ export { codeScanningPhase };
@@ -0,0 +1,106 @@
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 { DeploymentEnvironment } from "@effected/github";
7
+
8
+ //#region src/sync/phases/environments.ts
9
+ /**
10
+ * Stands in for live state this phase cannot read.
11
+ *
12
+ * @remarks
13
+ * `decide` needs three fingerprints, and GitHub exposes no read of these
14
+ * resources comparable to what is written — the PATCH surface and the GET
15
+ * surface differ, and some fields are not on the GET at all. So live is taken
16
+ * to be the last applied value, and this sentinel covers the case where there
17
+ * is no last applied value.
18
+ *
19
+ * It must never equal a real fingerprint. Using the desired fingerprint here
20
+ * instead would make `FirstSync` compute `needsApply: desired !== live` as
21
+ * `false` — and the very first sync of a repository would write nothing at all.
22
+ *
23
+ * The consequence, stated plainly: **this phase reports config change, not
24
+ * drift.** A baseline that differs reads as `ConfigChanged`, never as `Drift`,
25
+ * because there is no independent observation to attribute the difference to.
26
+ */
27
+ const UNREADABLE = "\0unreadable";
28
+ const KIND = "environment";
29
+ /**
30
+ * Deployment environments.
31
+ *
32
+ * @remarks
33
+ * **Runs before secrets and variables**, because an environment-scoped secret
34
+ * cannot be written to an environment that does not exist yet. That ordering
35
+ * lives in `PHASE_NAMES`; this phase only relies on it.
36
+ *
37
+ * Each environment is its own drift resource, so someone adding a required
38
+ * reviewer to `production` in the UI is reported as `production` changing
39
+ * rather than as "environments changed".
40
+ *
41
+ * An environment named in a group but missing from `[environments.*]` is
42
+ * skipped rather than erroring — cross-reference validation is
43
+ * `danglingReferences`'s job — run by `validate` and by `sync` before it writes
44
+ * anything — and duplicating it here would report the same
45
+ * mistake twice with less context.
46
+ *
47
+ * @public
48
+ */
49
+ const environmentsPhase = Effect.gen(function* () {
50
+ const environments = yield* DeploymentEnvironment;
51
+ const applied = yield* AppliedState;
52
+ const logger = yield* SyncLogger;
53
+ const run = (ctx) => Effect.gen(function* () {
54
+ const refs = ctx.config.groups[ctx.group]?.environments ?? [];
55
+ if (refs.length === 0) return {
56
+ changes: [],
57
+ errors: []
58
+ };
59
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
60
+ const changes = [];
61
+ const errors = [];
62
+ for (const name of refs) {
63
+ const desired = ctx.config.environments[name];
64
+ if (desired === void 0) continue;
65
+ const baseline = Option.map(lookup(baselines, KIND, name), (record) => record.fingerprint);
66
+ const desiredPrint = fingerprint(desired);
67
+ const decision = decide(desiredPrint, Option.getOrElse(baseline, () => UNREADABLE), baseline);
68
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, name, decision);
69
+ if (!needsApply(decision)) continue;
70
+ if (!ctx.dryRun) {
71
+ const failure = yield* environments.upsert(name, desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
72
+ if (Option.isSome(failure)) {
73
+ errors.push({
74
+ context: `environment ${name}`,
75
+ message: failure.value
76
+ });
77
+ continue;
78
+ }
79
+ yield* applied.record({
80
+ repo: ctx.slug,
81
+ kind: KIND,
82
+ name
83
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
84
+ }
85
+ yield* logger.syncOperation("sync", KIND, name);
86
+ changes.push({
87
+ repo: ctx.slug,
88
+ kind: KIND,
89
+ name,
90
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
91
+ });
92
+ }
93
+ return {
94
+ changes,
95
+ errors
96
+ };
97
+ });
98
+ return {
99
+ name: "environments",
100
+ appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.environments?.length ?? 0) > 0,
101
+ run
102
+ };
103
+ });
104
+
105
+ //#endregion
106
+ export { environmentsPhase };
@@ -0,0 +1,39 @@
1
+ import { capture, declaredNames, resolveGroups } from "./resource.js";
2
+ import { cleanupPhase } from "./cleanup.js";
3
+ import { codeScanningPhase } from "./code-scanning.js";
4
+ import { environmentsPhase } from "./environments.js";
5
+ import { rulesetsPhase } from "./rulesets.js";
6
+ import { secretsPhase } from "./secrets.js";
7
+ import { securityPhase } from "./security.js";
8
+ import { settingsPhase } from "./settings.js";
9
+ import { variablesPhase } from "./variables.js";
10
+ import { Effect } from "effect";
11
+
12
+ //#region src/sync/phases/index.ts
13
+ /**
14
+ * Every phase, in `PHASE_NAMES` order.
15
+ *
16
+ * @remarks
17
+ * The order is the contract, not a preference — environments exist before the
18
+ * secrets scoped to them — so the array is built in that order here rather than
19
+ * sorted later. `SyncEngineLive` takes this and walks it.
20
+ *
21
+ * `cleanup` runs last because every other phase's writes are what define
22
+ * "declared" — sweeping before them would delete a resource this very run was
23
+ * about to create.
24
+ *
25
+ * @public
26
+ */
27
+ const allPhases = Effect.all([
28
+ settingsPhase,
29
+ securityPhase,
30
+ codeScanningPhase,
31
+ environmentsPhase,
32
+ secretsPhase,
33
+ variablesPhase,
34
+ rulesetsPhase,
35
+ cleanupPhase
36
+ ]);
37
+
38
+ //#endregion
39
+ export { allPhases };