@fuzdev/fuz_gitops 0.75.0 → 0.77.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/dist/ci_reconcile.d.ts +50 -0
- package/dist/ci_reconcile.d.ts.map +1 -0
- package/dist/ci_reconcile.js +46 -0
- package/dist/config_reconcile.d.ts +46 -0
- package/dist/config_reconcile.d.ts.map +1 -0
- package/dist/config_reconcile.js +60 -0
- package/dist/gitops_config.d.ts +25 -0
- package/dist/gitops_config.d.ts.map +1 -1
- package/dist/gitops_config.js +12 -1
- package/dist/gitops_validate.task.d.ts.map +1 -1
- package/dist/gitops_validate.task.js +54 -6
- package/package.json +6 -5
- package/src/lib/ci_reconcile.ts +73 -0
- package/src/lib/config_reconcile.ts +94 -0
- package/src/lib/gitops_config.ts +41 -1
- package/src/lib/gitops_validate.task.ts +57 -5
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles each repo's declared `ci` flag against whether it actually has
|
|
3
|
+
* GitHub Actions workflow files on disk.
|
|
4
|
+
*
|
|
5
|
+
* The gitops config derives `ci` from visibility (on for public repos, off for
|
|
6
|
+
* private) with per-repo overrides; this check catches drift between that
|
|
7
|
+
* declaration and reality —
|
|
8
|
+
* a repo that claims CI but ships no workflow, or one that disclaims CI yet
|
|
9
|
+
* still carries one. Repos that aren't checked out locally can't be judged, so
|
|
10
|
+
* the caller marks them uncheckable and they're skipped. Archived repos are
|
|
11
|
+
* frozen on their host, so their CI state is intentionally left alone and they're
|
|
12
|
+
* skipped too.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
/** How a repo's declared `ci` diverges from its workflow files on disk. */
|
|
17
|
+
export type CiDriftKind =
|
|
18
|
+
/** `ci` is `true` but the repo has no workflow files. */
|
|
19
|
+
'missing_ci'
|
|
20
|
+
/** `ci` is `false` but the repo has workflow files. */
|
|
21
|
+
| 'stray_ci';
|
|
22
|
+
export interface CiDrift {
|
|
23
|
+
repo_url: string;
|
|
24
|
+
/** The declared/derived `ci` value. */
|
|
25
|
+
ci: boolean;
|
|
26
|
+
has_workflows: boolean;
|
|
27
|
+
kind: CiDriftKind;
|
|
28
|
+
}
|
|
29
|
+
export interface CiReconcileInput {
|
|
30
|
+
repo_url: string;
|
|
31
|
+
/** The declared/derived `ci` value from the gitops config. */
|
|
32
|
+
ci: boolean;
|
|
33
|
+
/** Whether the repo has at least one workflow file on disk. */
|
|
34
|
+
has_workflows: boolean;
|
|
35
|
+
/** Whether the repo is checked out locally; uncheckable repos are skipped. */
|
|
36
|
+
checkable: boolean;
|
|
37
|
+
/** Whether the repo is archived (frozen) on its host; archived repos are skipped. */
|
|
38
|
+
archived: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compares each repo's declared `ci` against its actual workflow presence.
|
|
42
|
+
* @returns one `CiDrift` per repo whose declaration and reality disagree
|
|
43
|
+
*/
|
|
44
|
+
export declare const reconcile_ci: (repos: Array<CiReconcileInput>) => Array<CiDrift>;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a local repo directory contains at least one GitHub Actions workflow.
|
|
47
|
+
* @param repo_dir - absolute or cwd-relative path to the repo's local directory
|
|
48
|
+
*/
|
|
49
|
+
export declare const repo_has_workflows: (repo_dir: string) => boolean;
|
|
50
|
+
//# sourceMappingURL=ci_reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ci_reconcile.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/ci_reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,2EAA2E;AAC3E,MAAM,MAAM,WAAW;AACtB,yDAAyD;AACvD,YAAY;AACd,uDAAuD;GACrD,UAAU,CAAC;AAEd,MAAM,WAAW,OAAO;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,EAAE,EAAE,OAAO,CAAC;IACZ,+DAA+D;IAC/D,aAAa,EAAE,OAAO,CAAC;IACvB,8EAA8E;IAC9E,SAAS,EAAE,OAAO,CAAC;IACnB,qFAAqF;IACrF,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,CAAC,gBAAgB,CAAC,KAAG,KAAK,CAAC,OAAO,CAY1E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,OAIrD,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles each repo's declared `ci` flag against whether it actually has
|
|
3
|
+
* GitHub Actions workflow files on disk.
|
|
4
|
+
*
|
|
5
|
+
* The gitops config derives `ci` from visibility (on for public repos, off for
|
|
6
|
+
* private) with per-repo overrides; this check catches drift between that
|
|
7
|
+
* declaration and reality —
|
|
8
|
+
* a repo that claims CI but ships no workflow, or one that disclaims CI yet
|
|
9
|
+
* still carries one. Repos that aren't checked out locally can't be judged, so
|
|
10
|
+
* the caller marks them uncheckable and they're skipped. Archived repos are
|
|
11
|
+
* frozen on their host, so their CI state is intentionally left alone and they're
|
|
12
|
+
* skipped too.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/**
|
|
19
|
+
* Compares each repo's declared `ci` against its actual workflow presence.
|
|
20
|
+
* @returns one `CiDrift` per repo whose declaration and reality disagree
|
|
21
|
+
*/
|
|
22
|
+
export const reconcile_ci = (repos) => {
|
|
23
|
+
const drift = [];
|
|
24
|
+
for (const repo of repos) {
|
|
25
|
+
if (!repo.checkable || repo.archived)
|
|
26
|
+
continue;
|
|
27
|
+
const { repo_url, ci, has_workflows } = repo;
|
|
28
|
+
if (ci && !has_workflows) {
|
|
29
|
+
drift.push({ repo_url, ci, has_workflows, kind: 'missing_ci' });
|
|
30
|
+
}
|
|
31
|
+
else if (!ci && has_workflows) {
|
|
32
|
+
drift.push({ repo_url, ci, has_workflows, kind: 'stray_ci' });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return drift;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Whether a local repo directory contains at least one GitHub Actions workflow.
|
|
39
|
+
* @param repo_dir - absolute or cwd-relative path to the repo's local directory
|
|
40
|
+
*/
|
|
41
|
+
export const repo_has_workflows = (repo_dir) => {
|
|
42
|
+
const workflows_dir = join(repo_dir, '.github', 'workflows');
|
|
43
|
+
if (!existsSync(workflows_dir))
|
|
44
|
+
return false;
|
|
45
|
+
return readdirSync(workflows_dir).some((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
|
46
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles repo metadata across multiple gitops configs.
|
|
3
|
+
*
|
|
4
|
+
* The ecosystem keeps several gitops configs — a canonical superset plus
|
|
5
|
+
* narrower per-project subsets. They can't share one source of truth (the
|
|
6
|
+
* superset includes private repos that public configs may not reference), so the
|
|
7
|
+
* same repo is declared independently in each. This check flags when a subset's
|
|
8
|
+
* intrinsic fields drift from the canonical config, or when a subset lists a
|
|
9
|
+
* repo the canonical config doesn't. `repo_dir` is per-config and ignored.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { GitopsRepoConfig } from './gitops_config.js';
|
|
14
|
+
/** Repo fields that must agree across every config that lists the repo. */
|
|
15
|
+
declare const INTRINSIC_FIELDS: readonly ["visibility", "ci", "archived", "branch"];
|
|
16
|
+
export type IntrinsicField = (typeof INTRINSIC_FIELDS)[number];
|
|
17
|
+
export type ConfigDriftKind =
|
|
18
|
+
/** The repo is in a subset config but absent from the canonical config. */
|
|
19
|
+
'missing_from_canonical'
|
|
20
|
+
/** An intrinsic field disagrees between the subset and the canonical config. */
|
|
21
|
+
| 'field_mismatch';
|
|
22
|
+
export interface ConfigDrift {
|
|
23
|
+
repo_url: string;
|
|
24
|
+
/** Name of the subset config whose entry diverges. */
|
|
25
|
+
config: string;
|
|
26
|
+
kind: ConfigDriftKind;
|
|
27
|
+
/** The field that disagrees, set when `kind` is `'field_mismatch'`. */
|
|
28
|
+
field?: IntrinsicField;
|
|
29
|
+
/** The canonical config's value, set when `kind` is `'field_mismatch'`. */
|
|
30
|
+
canonical_value?: string;
|
|
31
|
+
/** The subset config's value, set when `kind` is `'field_mismatch'`. */
|
|
32
|
+
config_value?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface NamedRepos {
|
|
35
|
+
name: string;
|
|
36
|
+
repos: Array<GitopsRepoConfig>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compares each subset config against the canonical config. Repos that live only
|
|
40
|
+
* in the canonical config are fine — the subsets are intentional partial views.
|
|
41
|
+
* @returns one `ConfigDrift` per subset entry that is missing from the canonical
|
|
42
|
+
* config or whose intrinsic fields disagree with it
|
|
43
|
+
*/
|
|
44
|
+
export declare const reconcile_configs: (canonical: NamedRepos, subsets: Array<NamedRepos>) => Array<ConfigDrift>;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=config_reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config_reconcile.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/config_reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oBAAoB,CAAC;AAEzD,2EAA2E;AAC3E,QAAA,MAAM,gBAAgB,qDAAsD,CAAC;AAE7E,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE/D,MAAM,MAAM,eAAe;AAC1B,2EAA2E;AACzE,wBAAwB;AAC1B,gFAAgF;GAC9E,gBAAgB,CAAC;AAEpB,MAAM,WAAW,WAAW;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,eAAe,CAAC;IACtB,uEAAuE;IACvE,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,2EAA2E;IAC3E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;CAC/B;AAeD;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAC7B,WAAW,UAAU,EACrB,SAAS,KAAK,CAAC,UAAU,CAAC,KACxB,KAAK,CAAC,WAAW,CA2BnB,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles repo metadata across multiple gitops configs.
|
|
3
|
+
*
|
|
4
|
+
* The ecosystem keeps several gitops configs — a canonical superset plus
|
|
5
|
+
* narrower per-project subsets. They can't share one source of truth (the
|
|
6
|
+
* superset includes private repos that public configs may not reference), so the
|
|
7
|
+
* same repo is declared independently in each. This check flags when a subset's
|
|
8
|
+
* intrinsic fields drift from the canonical config, or when a subset lists a
|
|
9
|
+
* repo the canonical config doesn't. `repo_dir` is per-config and ignored.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** Repo fields that must agree across every config that lists the repo. */
|
|
14
|
+
const INTRINSIC_FIELDS = ['visibility', 'ci', 'archived', 'branch'];
|
|
15
|
+
const field_value = (repo, field) => {
|
|
16
|
+
switch (field) {
|
|
17
|
+
case 'visibility':
|
|
18
|
+
return repo.visibility;
|
|
19
|
+
case 'ci':
|
|
20
|
+
return String(repo.ci);
|
|
21
|
+
case 'archived':
|
|
22
|
+
return String(repo.archived);
|
|
23
|
+
case 'branch':
|
|
24
|
+
return repo.branch;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Compares each subset config against the canonical config. Repos that live only
|
|
29
|
+
* in the canonical config are fine — the subsets are intentional partial views.
|
|
30
|
+
* @returns one `ConfigDrift` per subset entry that is missing from the canonical
|
|
31
|
+
* config or whose intrinsic fields disagree with it
|
|
32
|
+
*/
|
|
33
|
+
export const reconcile_configs = (canonical, subsets) => {
|
|
34
|
+
const drift = [];
|
|
35
|
+
const canonical_by_url = new Map(canonical.repos.map((r) => [r.repo_url, r]));
|
|
36
|
+
for (const subset of subsets) {
|
|
37
|
+
for (const repo of subset.repos) {
|
|
38
|
+
const canon = canonical_by_url.get(repo.repo_url);
|
|
39
|
+
if (!canon) {
|
|
40
|
+
drift.push({ repo_url: repo.repo_url, config: subset.name, kind: 'missing_from_canonical' });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const field of INTRINSIC_FIELDS) {
|
|
44
|
+
const canonical_value = field_value(canon, field);
|
|
45
|
+
const config_value = field_value(repo, field);
|
|
46
|
+
if (canonical_value !== config_value) {
|
|
47
|
+
drift.push({
|
|
48
|
+
repo_url: repo.repo_url,
|
|
49
|
+
config: subset.name,
|
|
50
|
+
kind: 'field_mismatch',
|
|
51
|
+
field,
|
|
52
|
+
canonical_value,
|
|
53
|
+
config_value,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return drift;
|
|
60
|
+
};
|
package/dist/gitops_config.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface RawGitopsConfig {
|
|
|
21
21
|
repos?: Array<Url | RawGitopsRepoConfig>;
|
|
22
22
|
repos_dir?: string;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Visibility of a repo on its host, mirroring the host's own model
|
|
26
|
+
* (e.g. GitHub's `visibility` field). Named to avoid confusion with the npm
|
|
27
|
+
* `package.json` `private` flag, which is a separate publishing concern.
|
|
28
|
+
*/
|
|
29
|
+
export type GitopsRepoVisibility = 'public' | 'private';
|
|
24
30
|
export interface GitopsRepoConfig {
|
|
25
31
|
/**
|
|
26
32
|
* The HTTPS URL to the repo. Does not include a `.git` suffix.
|
|
@@ -47,11 +53,30 @@ export interface GitopsRepoConfig {
|
|
|
47
53
|
* The branch name to use when fetching the repo. Defaults to `main`.
|
|
48
54
|
*/
|
|
49
55
|
branch: GitBranch;
|
|
56
|
+
/**
|
|
57
|
+
* Visibility of the repo on its host. Defaults to `'public'`.
|
|
58
|
+
*/
|
|
59
|
+
visibility: GitopsRepoVisibility;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the repo runs CI. Defaults to `true` for public repos and `false`
|
|
62
|
+
* for private repos, unless set explicitly.
|
|
63
|
+
*/
|
|
64
|
+
ci: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the repo is archived (read-only) on its host. Defaults to `false`.
|
|
67
|
+
*/
|
|
68
|
+
archived: boolean;
|
|
50
69
|
}
|
|
51
70
|
export interface RawGitopsRepoConfig {
|
|
52
71
|
repo_url: Url;
|
|
53
72
|
repo_dir?: string | null;
|
|
54
73
|
branch?: GitBranch;
|
|
74
|
+
/** Visibility of the repo on its host. Defaults to `'public'`. */
|
|
75
|
+
visibility?: GitopsRepoVisibility;
|
|
76
|
+
/** Whether the repo runs CI. Defaults to `true` for public, `false` for private. */
|
|
77
|
+
ci?: boolean;
|
|
78
|
+
/** Whether the repo is archived (read-only) on its host. Defaults to `false`. */
|
|
79
|
+
archived?: boolean;
|
|
55
80
|
}
|
|
56
81
|
export declare const create_empty_gitops_config: () => GitopsConfig;
|
|
57
82
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gitops_config.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/gitops_config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,GAAG,EAAC,MAAM,yBAAyB,CAAC;AAGjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yBAAyB,CAAC;AAIvD,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,kBAAkB,GAAG,CAChC,WAAW,EAAE,YAAY,KACrB,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,GAAG,GAAG,mBAAmB,CAAC,CAAC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAChC;;;;;;OAMG;IACH,QAAQ,EAAE,GAAG,CAAC;IAEd;;;;;;;;;;;OAWG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB;;OAEG;IACH,MAAM,EAAE,SAAS,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,GAAG,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,eAAO,MAAM,0BAA0B,QAAO,YAG5C,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,GAAI,YAAY,eAAe,KAAG,YAUrE,CAAC;
|
|
1
|
+
{"version":3,"file":"gitops_config.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/gitops_config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,GAAG,EAAC,MAAM,yBAAyB,CAAC;AAGjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,yBAAyB,CAAC;AAIvD,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,kBAAkB,GAAG,CAChC,WAAW,EAAE,YAAY,KACrB,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,GAAG,GAAG,mBAAmB,CAAC,CAAC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAChC;;;;;;OAMG;IACH,QAAQ,EAAE,GAAG,CAAC;IAEd;;;;;;;;;;;OAWG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAExB;;OAEG;IACH,MAAM,EAAE,SAAS,CAAC;IAElB;;OAEG;IACH,UAAU,EAAE,oBAAoB,CAAC;IAEjC;;;OAGG;IACH,EAAE,EAAE,OAAO,CAAC;IAEZ;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,GAAG,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,kEAAkE;IAClE,UAAU,CAAC,EAAE,oBAAoB,CAAC;IAClC,oFAAoF;IACpF,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,0BAA0B,QAAO,YAG5C,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,GAAI,YAAY,eAAe,KAAG,YAUrE,CAAC;AAwBF,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,kBAAkB,CAAC;CACvD;AAED,eAAO,MAAM,kBAAkB,GAAU,aAAa,MAAM,KAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAazF,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,CAC3C,aAAa,EAAE,GAAG,EAClB,WAAW,EAAE,MAAM,KACf,OAAO,CAAC,aAAa,IAAI,kBAS7B,CAAC"}
|
package/dist/gitops_config.js
CHANGED
|
@@ -34,12 +34,23 @@ export const normalize_gitops_config = (raw_config) => {
|
|
|
34
34
|
};
|
|
35
35
|
const parse_fuz_repo_config = (r) => {
|
|
36
36
|
if (typeof r === 'string') {
|
|
37
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
repo_url: r,
|
|
39
|
+
repo_dir: null,
|
|
40
|
+
branch: 'main', // TODO @zts use flavored for GitBranch
|
|
41
|
+
visibility: 'public',
|
|
42
|
+
ci: true,
|
|
43
|
+
archived: false,
|
|
44
|
+
};
|
|
38
45
|
}
|
|
46
|
+
const visibility = r.visibility ?? 'public';
|
|
39
47
|
return {
|
|
40
48
|
repo_url: strip_end(r.repo_url, '.git'),
|
|
41
49
|
repo_dir: r.repo_dir ?? null,
|
|
42
50
|
branch: r.branch ?? 'main', // TODO @zts use flavored for GitBranch
|
|
51
|
+
visibility,
|
|
52
|
+
ci: r.ci ?? visibility === 'public',
|
|
53
|
+
archived: r.archived ?? false,
|
|
43
54
|
};
|
|
44
55
|
};
|
|
45
56
|
export const load_gitops_config = async (config_path) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gitops_validate.task.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/gitops_validate.task.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"gitops_validate.task.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/gitops_validate.task.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,aAAa,CAAC;AACtC,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAetB,cAAc;AACd,eAAO,MAAM,IAAI;;;;;kBAiBf,CAAC;AACH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;AAExC,cAAc;AACd,eAAO,MAAM,IAAI,EAAE,IAAI,CAAC,IAAI,CA8Q3B,CAAC"}
|
|
@@ -6,6 +6,7 @@ import { generate_publishing_plan, log_publishing_plan, } from './publishing_pla
|
|
|
6
6
|
import { execute_publishing_plan } from './multi_repo_publisher.js';
|
|
7
7
|
import { log_dependency_analysis } from './log_helpers.js';
|
|
8
8
|
import { GITOPS_CONFIG_PATH_DEFAULT } from './gitops_constants.js';
|
|
9
|
+
import { reconcile_ci, repo_has_workflows } from './ci_reconcile.js';
|
|
9
10
|
/** @nodocs */
|
|
10
11
|
export const Args = z.strictObject({
|
|
11
12
|
config: z
|
|
@@ -58,7 +59,7 @@ export const task = {
|
|
|
58
59
|
const errors = analysis.production_cycles.length > 0 ? 1 : 0;
|
|
59
60
|
results.push({
|
|
60
61
|
command: 'gitops_analyze',
|
|
61
|
-
success:
|
|
62
|
+
success: errors === 0,
|
|
62
63
|
warnings,
|
|
63
64
|
errors,
|
|
64
65
|
duration: analyze_duration,
|
|
@@ -96,7 +97,7 @@ export const task = {
|
|
|
96
97
|
const errors = plan.errors.length;
|
|
97
98
|
results.push({
|
|
98
99
|
command: 'gitops_plan',
|
|
99
|
-
success:
|
|
100
|
+
success: errors === 0,
|
|
100
101
|
warnings,
|
|
101
102
|
errors,
|
|
102
103
|
duration: plan_duration,
|
|
@@ -160,6 +161,53 @@ export const task = {
|
|
|
160
161
|
});
|
|
161
162
|
log.error(st('red', ` ✗ gitops_publish (dry run) failed: ${error}`));
|
|
162
163
|
}
|
|
164
|
+
// 4. Reconcile each repo's declared `ci` against actual workflow files on disk.
|
|
165
|
+
log.info(st('yellow', 'Running ci_reconcile...'));
|
|
166
|
+
const ci_start = Date.now();
|
|
167
|
+
try {
|
|
168
|
+
const ci_drift = reconcile_ci(local_repos.map((r) => ({
|
|
169
|
+
repo_url: r.repo_config.repo_url,
|
|
170
|
+
ci: r.repo_config.ci,
|
|
171
|
+
has_workflows: repo_has_workflows(r.repo_dir),
|
|
172
|
+
// TODO: `local_repos` only ever holds checked-out repos — a missing repo
|
|
173
|
+
// throws in `local_repos_ensure` before we reach here — so `checkable` is
|
|
174
|
+
// always `true` today. The gate exists for a future caller that loads a
|
|
175
|
+
// partial set; until then the skip path is inert and untested.
|
|
176
|
+
checkable: true,
|
|
177
|
+
archived: r.repo_config.archived,
|
|
178
|
+
})));
|
|
179
|
+
const ci_duration = Date.now() - ci_start;
|
|
180
|
+
const drift_details = ci_drift.map((d) => d.kind === 'missing_ci'
|
|
181
|
+
? `${d.repo_url}: ci=true but no workflow files`
|
|
182
|
+
: `${d.repo_url}: ci=false but workflow files present`);
|
|
183
|
+
results.push({
|
|
184
|
+
command: 'ci_reconcile',
|
|
185
|
+
success: ci_drift.length === 0,
|
|
186
|
+
warnings: 0,
|
|
187
|
+
errors: ci_drift.length,
|
|
188
|
+
duration: ci_duration,
|
|
189
|
+
});
|
|
190
|
+
if (ci_drift.length === 0) {
|
|
191
|
+
log.info(st('green', ` ✓ ci_reconcile completed in ${ci_duration}ms`));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
log.error(st('red', ` ❌ ci_reconcile found ${ci_drift.length} drift(s)`));
|
|
195
|
+
for (const detail of drift_details) {
|
|
196
|
+
log.error(st('red', ` - ${detail}`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const ci_duration = Date.now() - ci_start;
|
|
202
|
+
results.push({
|
|
203
|
+
command: 'ci_reconcile',
|
|
204
|
+
success: false,
|
|
205
|
+
warnings: 0,
|
|
206
|
+
errors: 1,
|
|
207
|
+
duration: ci_duration,
|
|
208
|
+
});
|
|
209
|
+
log.error(st('red', ` ✗ ci_reconcile failed: ${error}`));
|
|
210
|
+
}
|
|
163
211
|
// Summary
|
|
164
212
|
const total_duration = Date.now() - start_time;
|
|
165
213
|
const all_success = results.every((r) => r.success);
|
|
@@ -200,11 +248,11 @@ export const task = {
|
|
|
200
248
|
log.warn(st('yellow', `⚠️ Note: ${total_warnings} warning(s) found - review output above.`));
|
|
201
249
|
}
|
|
202
250
|
}
|
|
203
|
-
else if (all_success && total_errors > 0) {
|
|
204
|
-
log.warn(st('yellow', '⚠️ Validation completed but found errors - review output above.'));
|
|
205
|
-
}
|
|
206
251
|
else {
|
|
207
|
-
|
|
252
|
+
// Hard-fail on any error or failed command. These run manually (and
|
|
253
|
+
// increasingly via agents), so a clear problem should stop the pipeline
|
|
254
|
+
// rather than scroll past in the summary. Warnings stay non-fatal.
|
|
255
|
+
log.error(st('red', '❌ Validation failed - review the errors above.'));
|
|
208
256
|
throw new Error('Validation failed');
|
|
209
257
|
}
|
|
210
258
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_gitops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "a tool for managing many repos",
|
|
5
5
|
"glyph": "🪄",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@fuzdev/fuz_css": ">=0.62.0",
|
|
33
|
-
"@fuzdev/fuz_ui": "
|
|
33
|
+
"@fuzdev/fuz_ui": ">=0.205.1",
|
|
34
34
|
"@fuzdev/fuz_util": ">=0.65.1",
|
|
35
35
|
"@fuzdev/gro": ">=0.204.0",
|
|
36
36
|
"@sveltejs/kit": "^2",
|
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@changesets/changelog-git": "^0.2.1",
|
|
42
42
|
"@fuzdev/blake3_wasm": "^0.1.1",
|
|
43
|
-
"@fuzdev/fuz_code": "^0.46.
|
|
44
|
-
"@fuzdev/fuz_css": "^0.
|
|
45
|
-
"@fuzdev/fuz_ui": "^0.
|
|
43
|
+
"@fuzdev/fuz_code": "^0.46.1",
|
|
44
|
+
"@fuzdev/fuz_css": "^0.63.2",
|
|
45
|
+
"@fuzdev/fuz_ui": "^0.205.1",
|
|
46
46
|
"@fuzdev/fuz_util": "^0.65.1",
|
|
47
47
|
"@fuzdev/gro": "^0.204.0",
|
|
48
|
+
"@fuzdev/mdz": "^0.1.0",
|
|
48
49
|
"@ryanatkn/eslint-config": "^0.12.1",
|
|
49
50
|
"@sveltejs/acorn-typescript": "^1.0.9",
|
|
50
51
|
"@sveltejs/adapter-static": "^3.0.10",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles each repo's declared `ci` flag against whether it actually has
|
|
3
|
+
* GitHub Actions workflow files on disk.
|
|
4
|
+
*
|
|
5
|
+
* The gitops config derives `ci` from visibility (on for public repos, off for
|
|
6
|
+
* private) with per-repo overrides; this check catches drift between that
|
|
7
|
+
* declaration and reality —
|
|
8
|
+
* a repo that claims CI but ships no workflow, or one that disclaims CI yet
|
|
9
|
+
* still carries one. Repos that aren't checked out locally can't be judged, so
|
|
10
|
+
* the caller marks them uncheckable and they're skipped. Archived repos are
|
|
11
|
+
* frozen on their host, so their CI state is intentionally left alone and they're
|
|
12
|
+
* skipped too.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {existsSync, readdirSync} from 'node:fs';
|
|
18
|
+
import {join} from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** How a repo's declared `ci` diverges from its workflow files on disk. */
|
|
21
|
+
export type CiDriftKind =
|
|
22
|
+
/** `ci` is `true` but the repo has no workflow files. */
|
|
23
|
+
| 'missing_ci'
|
|
24
|
+
/** `ci` is `false` but the repo has workflow files. */
|
|
25
|
+
| 'stray_ci';
|
|
26
|
+
|
|
27
|
+
export interface CiDrift {
|
|
28
|
+
repo_url: string;
|
|
29
|
+
/** The declared/derived `ci` value. */
|
|
30
|
+
ci: boolean;
|
|
31
|
+
has_workflows: boolean;
|
|
32
|
+
kind: CiDriftKind;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CiReconcileInput {
|
|
36
|
+
repo_url: string;
|
|
37
|
+
/** The declared/derived `ci` value from the gitops config. */
|
|
38
|
+
ci: boolean;
|
|
39
|
+
/** Whether the repo has at least one workflow file on disk. */
|
|
40
|
+
has_workflows: boolean;
|
|
41
|
+
/** Whether the repo is checked out locally; uncheckable repos are skipped. */
|
|
42
|
+
checkable: boolean;
|
|
43
|
+
/** Whether the repo is archived (frozen) on its host; archived repos are skipped. */
|
|
44
|
+
archived: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compares each repo's declared `ci` against its actual workflow presence.
|
|
49
|
+
* @returns one `CiDrift` per repo whose declaration and reality disagree
|
|
50
|
+
*/
|
|
51
|
+
export const reconcile_ci = (repos: Array<CiReconcileInput>): Array<CiDrift> => {
|
|
52
|
+
const drift: Array<CiDrift> = [];
|
|
53
|
+
for (const repo of repos) {
|
|
54
|
+
if (!repo.checkable || repo.archived) continue;
|
|
55
|
+
const {repo_url, ci, has_workflows} = repo;
|
|
56
|
+
if (ci && !has_workflows) {
|
|
57
|
+
drift.push({repo_url, ci, has_workflows, kind: 'missing_ci'});
|
|
58
|
+
} else if (!ci && has_workflows) {
|
|
59
|
+
drift.push({repo_url, ci, has_workflows, kind: 'stray_ci'});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return drift;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a local repo directory contains at least one GitHub Actions workflow.
|
|
67
|
+
* @param repo_dir - absolute or cwd-relative path to the repo's local directory
|
|
68
|
+
*/
|
|
69
|
+
export const repo_has_workflows = (repo_dir: string): boolean => {
|
|
70
|
+
const workflows_dir = join(repo_dir, '.github', 'workflows');
|
|
71
|
+
if (!existsSync(workflows_dir)) return false;
|
|
72
|
+
return readdirSync(workflows_dir).some((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
|
73
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles repo metadata across multiple gitops configs.
|
|
3
|
+
*
|
|
4
|
+
* The ecosystem keeps several gitops configs — a canonical superset plus
|
|
5
|
+
* narrower per-project subsets. They can't share one source of truth (the
|
|
6
|
+
* superset includes private repos that public configs may not reference), so the
|
|
7
|
+
* same repo is declared independently in each. This check flags when a subset's
|
|
8
|
+
* intrinsic fields drift from the canonical config, or when a subset lists a
|
|
9
|
+
* repo the canonical config doesn't. `repo_dir` is per-config and ignored.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {GitopsRepoConfig} from './gitops_config.js';
|
|
15
|
+
|
|
16
|
+
/** Repo fields that must agree across every config that lists the repo. */
|
|
17
|
+
const INTRINSIC_FIELDS = ['visibility', 'ci', 'archived', 'branch'] as const;
|
|
18
|
+
|
|
19
|
+
export type IntrinsicField = (typeof INTRINSIC_FIELDS)[number];
|
|
20
|
+
|
|
21
|
+
export type ConfigDriftKind =
|
|
22
|
+
/** The repo is in a subset config but absent from the canonical config. */
|
|
23
|
+
| 'missing_from_canonical'
|
|
24
|
+
/** An intrinsic field disagrees between the subset and the canonical config. */
|
|
25
|
+
| 'field_mismatch';
|
|
26
|
+
|
|
27
|
+
export interface ConfigDrift {
|
|
28
|
+
repo_url: string;
|
|
29
|
+
/** Name of the subset config whose entry diverges. */
|
|
30
|
+
config: string;
|
|
31
|
+
kind: ConfigDriftKind;
|
|
32
|
+
/** The field that disagrees, set when `kind` is `'field_mismatch'`. */
|
|
33
|
+
field?: IntrinsicField;
|
|
34
|
+
/** The canonical config's value, set when `kind` is `'field_mismatch'`. */
|
|
35
|
+
canonical_value?: string;
|
|
36
|
+
/** The subset config's value, set when `kind` is `'field_mismatch'`. */
|
|
37
|
+
config_value?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface NamedRepos {
|
|
41
|
+
name: string;
|
|
42
|
+
repos: Array<GitopsRepoConfig>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const field_value = (repo: GitopsRepoConfig, field: IntrinsicField): string => {
|
|
46
|
+
switch (field) {
|
|
47
|
+
case 'visibility':
|
|
48
|
+
return repo.visibility;
|
|
49
|
+
case 'ci':
|
|
50
|
+
return String(repo.ci);
|
|
51
|
+
case 'archived':
|
|
52
|
+
return String(repo.archived);
|
|
53
|
+
case 'branch':
|
|
54
|
+
return repo.branch;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compares each subset config against the canonical config. Repos that live only
|
|
60
|
+
* in the canonical config are fine — the subsets are intentional partial views.
|
|
61
|
+
* @returns one `ConfigDrift` per subset entry that is missing from the canonical
|
|
62
|
+
* config or whose intrinsic fields disagree with it
|
|
63
|
+
*/
|
|
64
|
+
export const reconcile_configs = (
|
|
65
|
+
canonical: NamedRepos,
|
|
66
|
+
subsets: Array<NamedRepos>,
|
|
67
|
+
): Array<ConfigDrift> => {
|
|
68
|
+
const drift: Array<ConfigDrift> = [];
|
|
69
|
+
const canonical_by_url = new Map(canonical.repos.map((r) => [r.repo_url, r] as const));
|
|
70
|
+
for (const subset of subsets) {
|
|
71
|
+
for (const repo of subset.repos) {
|
|
72
|
+
const canon = canonical_by_url.get(repo.repo_url);
|
|
73
|
+
if (!canon) {
|
|
74
|
+
drift.push({repo_url: repo.repo_url, config: subset.name, kind: 'missing_from_canonical'});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const field of INTRINSIC_FIELDS) {
|
|
78
|
+
const canonical_value = field_value(canon, field);
|
|
79
|
+
const config_value = field_value(repo, field);
|
|
80
|
+
if (canonical_value !== config_value) {
|
|
81
|
+
drift.push({
|
|
82
|
+
repo_url: repo.repo_url,
|
|
83
|
+
config: subset.name,
|
|
84
|
+
kind: 'field_mismatch',
|
|
85
|
+
field,
|
|
86
|
+
canonical_value,
|
|
87
|
+
config_value,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return drift;
|
|
94
|
+
};
|
package/src/lib/gitops_config.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface RawGitopsConfig {
|
|
|
32
32
|
repos_dir?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Visibility of a repo on its host, mirroring the host's own model
|
|
37
|
+
* (e.g. GitHub's `visibility` field). Named to avoid confusion with the npm
|
|
38
|
+
* `package.json` `private` flag, which is a separate publishing concern.
|
|
39
|
+
*/
|
|
40
|
+
export type GitopsRepoVisibility = 'public' | 'private';
|
|
41
|
+
|
|
35
42
|
export interface GitopsRepoConfig {
|
|
36
43
|
/**
|
|
37
44
|
* The HTTPS URL to the repo. Does not include a `.git` suffix.
|
|
@@ -60,12 +67,34 @@ export interface GitopsRepoConfig {
|
|
|
60
67
|
* The branch name to use when fetching the repo. Defaults to `main`.
|
|
61
68
|
*/
|
|
62
69
|
branch: GitBranch;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Visibility of the repo on its host. Defaults to `'public'`.
|
|
73
|
+
*/
|
|
74
|
+
visibility: GitopsRepoVisibility;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the repo runs CI. Defaults to `true` for public repos and `false`
|
|
78
|
+
* for private repos, unless set explicitly.
|
|
79
|
+
*/
|
|
80
|
+
ci: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the repo is archived (read-only) on its host. Defaults to `false`.
|
|
84
|
+
*/
|
|
85
|
+
archived: boolean;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
export interface RawGitopsRepoConfig {
|
|
66
89
|
repo_url: Url;
|
|
67
90
|
repo_dir?: string | null;
|
|
68
91
|
branch?: GitBranch;
|
|
92
|
+
/** Visibility of the repo on its host. Defaults to `'public'`. */
|
|
93
|
+
visibility?: GitopsRepoVisibility;
|
|
94
|
+
/** Whether the repo runs CI. Defaults to `true` for public, `false` for private. */
|
|
95
|
+
ci?: boolean;
|
|
96
|
+
/** Whether the repo is archived (read-only) on its host. Defaults to `false`. */
|
|
97
|
+
archived?: boolean;
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
export const create_empty_gitops_config = (): GitopsConfig => ({
|
|
@@ -91,12 +120,23 @@ export const normalize_gitops_config = (raw_config: RawGitopsConfig): GitopsConf
|
|
|
91
120
|
|
|
92
121
|
const parse_fuz_repo_config = (r: Url | RawGitopsRepoConfig): GitopsRepoConfig => {
|
|
93
122
|
if (typeof r === 'string') {
|
|
94
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
repo_url: r,
|
|
125
|
+
repo_dir: null,
|
|
126
|
+
branch: 'main' as GitBranch, // TODO @zts use flavored for GitBranch
|
|
127
|
+
visibility: 'public',
|
|
128
|
+
ci: true,
|
|
129
|
+
archived: false,
|
|
130
|
+
};
|
|
95
131
|
}
|
|
132
|
+
const visibility = r.visibility ?? 'public';
|
|
96
133
|
return {
|
|
97
134
|
repo_url: strip_end(r.repo_url, '.git'),
|
|
98
135
|
repo_dir: r.repo_dir ?? null,
|
|
99
136
|
branch: r.branch ?? ('main' as GitBranch), // TODO @zts use flavored for GitBranch
|
|
137
|
+
visibility,
|
|
138
|
+
ci: r.ci ?? visibility === 'public',
|
|
139
|
+
archived: r.archived ?? false,
|
|
100
140
|
};
|
|
101
141
|
};
|
|
102
142
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {execute_publishing_plan, type PublishingOptions} from './multi_repo_publisher.js';
|
|
13
13
|
import {log_dependency_analysis} from './log_helpers.js';
|
|
14
14
|
import {GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';
|
|
15
|
+
import {reconcile_ci, repo_has_workflows} from './ci_reconcile.js';
|
|
15
16
|
|
|
16
17
|
/** @nodocs */
|
|
17
18
|
export const Args = z.strictObject({
|
|
@@ -86,7 +87,7 @@ export const task: Task<Args> = {
|
|
|
86
87
|
|
|
87
88
|
results.push({
|
|
88
89
|
command: 'gitops_analyze',
|
|
89
|
-
success:
|
|
90
|
+
success: errors === 0,
|
|
90
91
|
warnings,
|
|
91
92
|
errors,
|
|
92
93
|
duration: analyze_duration,
|
|
@@ -129,7 +130,7 @@ export const task: Task<Args> = {
|
|
|
129
130
|
|
|
130
131
|
results.push({
|
|
131
132
|
command: 'gitops_plan',
|
|
132
|
-
success:
|
|
133
|
+
success: errors === 0,
|
|
133
134
|
warnings,
|
|
134
135
|
errors,
|
|
135
136
|
duration: plan_duration,
|
|
@@ -202,6 +203,56 @@ export const task: Task<Args> = {
|
|
|
202
203
|
log.error(st('red', ` ✗ gitops_publish (dry run) failed: ${error}`));
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// 4. Reconcile each repo's declared `ci` against actual workflow files on disk.
|
|
207
|
+
log.info(st('yellow', 'Running ci_reconcile...'));
|
|
208
|
+
const ci_start = Date.now();
|
|
209
|
+
try {
|
|
210
|
+
const ci_drift = reconcile_ci(
|
|
211
|
+
local_repos.map((r) => ({
|
|
212
|
+
repo_url: r.repo_config.repo_url,
|
|
213
|
+
ci: r.repo_config.ci,
|
|
214
|
+
has_workflows: repo_has_workflows(r.repo_dir),
|
|
215
|
+
// TODO: `local_repos` only ever holds checked-out repos — a missing repo
|
|
216
|
+
// throws in `local_repos_ensure` before we reach here — so `checkable` is
|
|
217
|
+
// always `true` today. The gate exists for a future caller that loads a
|
|
218
|
+
// partial set; until then the skip path is inert and untested.
|
|
219
|
+
checkable: true,
|
|
220
|
+
archived: r.repo_config.archived,
|
|
221
|
+
})),
|
|
222
|
+
);
|
|
223
|
+
const ci_duration = Date.now() - ci_start;
|
|
224
|
+
const drift_details = ci_drift.map((d) =>
|
|
225
|
+
d.kind === 'missing_ci'
|
|
226
|
+
? `${d.repo_url}: ci=true but no workflow files`
|
|
227
|
+
: `${d.repo_url}: ci=false but workflow files present`,
|
|
228
|
+
);
|
|
229
|
+
results.push({
|
|
230
|
+
command: 'ci_reconcile',
|
|
231
|
+
success: ci_drift.length === 0,
|
|
232
|
+
warnings: 0,
|
|
233
|
+
errors: ci_drift.length,
|
|
234
|
+
duration: ci_duration,
|
|
235
|
+
});
|
|
236
|
+
if (ci_drift.length === 0) {
|
|
237
|
+
log.info(st('green', ` ✓ ci_reconcile completed in ${ci_duration}ms`));
|
|
238
|
+
} else {
|
|
239
|
+
log.error(st('red', ` ❌ ci_reconcile found ${ci_drift.length} drift(s)`));
|
|
240
|
+
for (const detail of drift_details) {
|
|
241
|
+
log.error(st('red', ` - ${detail}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const ci_duration = Date.now() - ci_start;
|
|
246
|
+
results.push({
|
|
247
|
+
command: 'ci_reconcile',
|
|
248
|
+
success: false,
|
|
249
|
+
warnings: 0,
|
|
250
|
+
errors: 1,
|
|
251
|
+
duration: ci_duration,
|
|
252
|
+
});
|
|
253
|
+
log.error(st('red', ` ✗ ci_reconcile failed: ${error}`));
|
|
254
|
+
}
|
|
255
|
+
|
|
205
256
|
// Summary
|
|
206
257
|
const total_duration = Date.now() - start_time;
|
|
207
258
|
const all_success = results.every((r) => r.success);
|
|
@@ -247,10 +298,11 @@ export const task: Task<Args> = {
|
|
|
247
298
|
st('yellow', `⚠️ Note: ${total_warnings} warning(s) found - review output above.`),
|
|
248
299
|
);
|
|
249
300
|
}
|
|
250
|
-
} else if (all_success && total_errors > 0) {
|
|
251
|
-
log.warn(st('yellow', '⚠️ Validation completed but found errors - review output above.'));
|
|
252
301
|
} else {
|
|
253
|
-
|
|
302
|
+
// Hard-fail on any error or failed command. These run manually (and
|
|
303
|
+
// increasingly via agents), so a clear problem should stop the pipeline
|
|
304
|
+
// rather than scroll past in the summary. Warnings stay non-fatal.
|
|
305
|
+
log.error(st('red', '❌ Validation failed - review the errors above.'));
|
|
254
306
|
throw new Error('Validation failed');
|
|
255
307
|
}
|
|
256
308
|
},
|