Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/calm-planes-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@fuzdev/fuz_gitops': minor
---

add host-state fields to repo config (groundwork)

- `RawGitopsRepoConfig` accepts optional `visibility` (`'public' | 'private'`, defaults to `'public'`), `ci`, and `archived` (defaults to `false`)
- `ci` defaults to `true` for public repos and `false` for private ones, overridable per-repo
- `reconcile_ci` flags drift between a repo's declared `ci` and its actual workflow files, skipping archived repos
- `gro gitops_validate` now runs `ci_reconcile` and hard-fails (throws) on any error from any step — a production dependency cycle, a plan error, or CI drift — instead of completing with a warning; warnings stay non-fatal
- not yet consumed by sync/publish
24 changes: 22 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,40 @@ on:
branches: [main]
pull_request:
branches: ['**']
# Allow manually re-running the check from the Actions tab.
workflow_dispatch:

# Cancel a PR's in-progress run when a new commit supersedes it; let main-branch
# runs finish so every commit on main stays verified.
concurrency:
group: check-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# Least privilege: the check only reads the repo.
permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
matrix:
node-version: ['24.14']

steps:
- uses: actions/checkout@v2
# persist-credentials: false keeps the GITHUB_TOKEN out of .git/config, so a
# compromised build dependency can't read it. If you add a step that pushes
# via git (deploy, tag, generated commit), set this back to true or
# authenticate that step explicitly.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npx @fuzdev/gro check --workspace --build
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ gro gitops_run "gro check" --format json # JSON output (logged to stdo
gro gitops_run "gro check" --format json --outfile out.json # clean JSON to a file

# Publishing
gro gitops_validate # validate configuration (runs analyze, plan, and dry run)
gro gitops_validate # validate configuration (runs analyze, plan, dry run, and ci_reconcile)
gro gitops_analyze # analyze dependencies and changesets
gro gitops_plan # generate publishing plan
gro gitops_plan --verbose # show additional details
Expand Down Expand Up @@ -436,7 +436,9 @@ refresh repos (switch to the configured branch, pull, install) first.
### Command Workflow

- `gitops_validate` runs: `gitops_analyze` + `gitops_plan` +
`gitops_publish` (dry run)
`gitops_publish` (dry run) + `ci_reconcile`. It hard-fails (throws) on any
error from any step — a production dependency cycle, a plan error, or CI
drift — so a clear problem stops the run. Warnings stay non-fatal.
- `gitops_publish --wetrun` runs: `gitops_plan` (with confirmation) + actual publish

## Dependencies
Expand Down
2 changes: 1 addition & 1 deletion gitops.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const config: CreateGitopsConfig = () => {
'https://github.com/fuzdev/fuz_blog',
'https://github.com/fuzdev/fuz_mastodon',
'https://github.com/fuzdev/fuz_code',
// {repo_url: 'https://github.com/fuzdev/mdz'},
'https://github.com/fuzdev/mdz',
'https://github.com/fuzdev/svelte-docinfo',
'https://github.com/fuzdev/tsv',
'https://github.com/fuzdev/tsv.fuz.dev',
Expand Down
73 changes: 73 additions & 0 deletions src/lib/ci_reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Reconciles each repo's declared `ci` flag against whether it actually has
* GitHub Actions workflow files on disk.
*
* The gitops config derives `ci` from visibility (on for public repos, off for
* private) with per-repo overrides; this check catches drift between that
* declaration and reality —
* a repo that claims CI but ships no workflow, or one that disclaims CI yet
* still carries one. Repos that aren't checked out locally can't be judged, so
* the caller marks them uncheckable and they're skipped. Archived repos are
* frozen on their host, so their CI state is intentionally left alone and they're
* skipped too.
*
* @module
*/

import {existsSync, readdirSync} from 'node:fs';
import {join} from 'node:path';

/** How a repo's declared `ci` diverges from its workflow files on disk. */
export type CiDriftKind =
/** `ci` is `true` but the repo has no workflow files. */
| 'missing_ci'
/** `ci` is `false` but the repo has workflow files. */
| 'stray_ci';

export interface CiDrift {
repo_url: string;
/** The declared/derived `ci` value. */
ci: boolean;
has_workflows: boolean;
kind: CiDriftKind;
}

export interface CiReconcileInput {
repo_url: string;
/** The declared/derived `ci` value from the gitops config. */
ci: boolean;
/** Whether the repo has at least one workflow file on disk. */
has_workflows: boolean;
/** Whether the repo is checked out locally; uncheckable repos are skipped. */
checkable: boolean;
/** Whether the repo is archived (frozen) on its host; archived repos are skipped. */
archived: boolean;
}

/**
* Compares each repo's declared `ci` against its actual workflow presence.
* @returns one `CiDrift` per repo whose declaration and reality disagree
*/
export const reconcile_ci = (repos: Array<CiReconcileInput>): Array<CiDrift> => {
const drift: Array<CiDrift> = [];
for (const repo of repos) {
if (!repo.checkable || repo.archived) continue;
const {repo_url, ci, has_workflows} = repo;
if (ci && !has_workflows) {
drift.push({repo_url, ci, has_workflows, kind: 'missing_ci'});
} else if (!ci && has_workflows) {
drift.push({repo_url, ci, has_workflows, kind: 'stray_ci'});
}
}
return drift;
};

/**
* Whether a local repo directory contains at least one GitHub Actions workflow.
* @param repo_dir - absolute or cwd-relative path to the repo's local directory
*/
export const repo_has_workflows = (repo_dir: string): boolean => {
const workflows_dir = join(repo_dir, '.github', 'workflows');
if (!existsSync(workflows_dir)) return false;
return readdirSync(workflows_dir).some((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
};
42 changes: 41 additions & 1 deletion src/lib/gitops_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export interface RawGitopsConfig {
repos_dir?: string;
}

/**
* Visibility of a repo on its host, mirroring the host's own model
* (e.g. GitHub's `visibility` field). Named to avoid confusion with the npm
* `package.json` `private` flag, which is a separate publishing concern.
*/
export type GitopsRepoVisibility = 'public' | 'private';

export interface GitopsRepoConfig {
/**
* The HTTPS URL to the repo. Does not include a `.git` suffix.
Expand Down Expand Up @@ -60,12 +67,34 @@ export interface GitopsRepoConfig {
* The branch name to use when fetching the repo. Defaults to `main`.
*/
branch: GitBranch;

/**
* Visibility of the repo on its host. Defaults to `'public'`.
*/
visibility: GitopsRepoVisibility;

/**
* Whether the repo runs CI. Defaults to `true` for public repos and `false`
* for private repos, unless set explicitly.
*/
ci: boolean;

/**
* Whether the repo is archived (read-only) on its host. Defaults to `false`.
*/
archived: boolean;
}

export interface RawGitopsRepoConfig {
repo_url: Url;
repo_dir?: string | null;
branch?: GitBranch;
/** Visibility of the repo on its host. Defaults to `'public'`. */
visibility?: GitopsRepoVisibility;
/** Whether the repo runs CI. Defaults to `true` for public, `false` for private. */
ci?: boolean;
/** Whether the repo is archived (read-only) on its host. Defaults to `false`. */
archived?: boolean;
}

export const create_empty_gitops_config = (): GitopsConfig => ({
Expand All @@ -91,12 +120,23 @@ export const normalize_gitops_config = (raw_config: RawGitopsConfig): GitopsConf

const parse_fuz_repo_config = (r: Url | RawGitopsRepoConfig): GitopsRepoConfig => {
if (typeof r === 'string') {
return {repo_url: r, repo_dir: null, branch: 'main' as GitBranch}; // TODO @zts use flavored for GitBranch
return {
repo_url: r,
repo_dir: null,
branch: 'main' as GitBranch, // TODO @zts use flavored for GitBranch
visibility: 'public',
ci: true,
archived: false,
};
}
const visibility = r.visibility ?? 'public';
return {
repo_url: strip_end(r.repo_url, '.git'),
repo_dir: r.repo_dir ?? null,
branch: r.branch ?? ('main' as GitBranch), // TODO @zts use flavored for GitBranch
visibility,
ci: r.ci ?? visibility === 'public',
archived: r.archived ?? false,
};
};

Expand Down
62 changes: 57 additions & 5 deletions src/lib/gitops_validate.task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {execute_publishing_plan, type PublishingOptions} from './multi_repo_publisher.js';
import {log_dependency_analysis} from './log_helpers.js';
import {GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';
import {reconcile_ci, repo_has_workflows} from './ci_reconcile.js';

/** @nodocs */
export const Args = z.strictObject({
Expand Down Expand Up @@ -86,7 +87,7 @@ export const task: Task<Args> = {

results.push({
command: 'gitops_analyze',
success: true,
success: errors === 0,
warnings,
errors,
duration: analyze_duration,
Expand Down Expand Up @@ -129,7 +130,7 @@ export const task: Task<Args> = {

results.push({
command: 'gitops_plan',
success: true,
success: errors === 0,
warnings,
errors,
duration: plan_duration,
Expand Down Expand Up @@ -202,6 +203,56 @@ export const task: Task<Args> = {
log.error(st('red', ` ✗ gitops_publish (dry run) failed: ${error}`));
}

// 4. Reconcile each repo's declared `ci` against actual workflow files on disk.
log.info(st('yellow', 'Running ci_reconcile...'));
const ci_start = Date.now();
try {
const ci_drift = reconcile_ci(
local_repos.map((r) => ({
repo_url: r.repo_config.repo_url,
ci: r.repo_config.ci,
has_workflows: repo_has_workflows(r.repo_dir),
// TODO: `local_repos` only ever holds checked-out repos — a missing repo
// throws in `local_repos_ensure` before we reach here — so `checkable` is
// always `true` today. The gate exists for a future caller that loads a
// partial set; until then the skip path is inert and untested.
checkable: true,
archived: r.repo_config.archived,
})),
);
const ci_duration = Date.now() - ci_start;
const drift_details = ci_drift.map((d) =>
d.kind === 'missing_ci'
? `${d.repo_url}: ci=true but no workflow files`
: `${d.repo_url}: ci=false but workflow files present`,
);
results.push({
command: 'ci_reconcile',
success: ci_drift.length === 0,
warnings: 0,
errors: ci_drift.length,
duration: ci_duration,
});
if (ci_drift.length === 0) {
log.info(st('green', ` ✓ ci_reconcile completed in ${ci_duration}ms`));
} else {
log.error(st('red', ` ❌ ci_reconcile found ${ci_drift.length} drift(s)`));
for (const detail of drift_details) {
log.error(st('red', ` - ${detail}`));
}
}
} catch (error) {
const ci_duration = Date.now() - ci_start;
results.push({
command: 'ci_reconcile',
success: false,
warnings: 0,
errors: 1,
duration: ci_duration,
});
log.error(st('red', ` ✗ ci_reconcile failed: ${error}`));
}

// Summary
const total_duration = Date.now() - start_time;
const all_success = results.every((r) => r.success);
Expand Down Expand Up @@ -247,10 +298,11 @@ export const task: Task<Args> = {
st('yellow', `⚠️ Note: ${total_warnings} warning(s) found - review output above.`),
);
}
} else if (all_success && total_errors > 0) {
log.warn(st('yellow', '⚠️ Validation completed but found errors - review output above.'));
} else {
log.error(st('red', '❌ Validation failed - one or more commands did not complete.'));
// Hard-fail on any error or failed command. These run manually (and
// increasingly via agents), so a clear problem should stop the pipeline
// rather than scroll past in the summary. Warnings stay non-fatal.
log.error(st('red', '❌ Validation failed - review the errors above.'));
throw new Error('Validation failed');
}
},
Expand Down
Loading