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
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 };
|