@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.
@@ -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
+ };
@@ -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;AAaF,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"}
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"}
@@ -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 { repo_url: r, repo_dir: null, branch: 'main' }; // TODO @zts use flavored for GitBranch
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;AActB,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,CA2N3B,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: true,
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: true,
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
- log.error(st('red', '❌ Validation failed - one or more commands did not complete.'));
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.75.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": "^0.203.0",
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.0",
44
- "@fuzdev/fuz_css": "^0.62.0",
45
- "@fuzdev/fuz_ui": "^0.203.1",
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
+ };
@@ -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 {repo_url: r, repo_dir: null, branch: 'main' as GitBranch}; // TODO @zts use flavored for GitBranch
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: true,
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: true,
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
- log.error(st('red', '❌ Validation failed - one or more commands did not complete.'));
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
  },