diff --git a/.changeset/calm-planes-accept.md b/.changeset/calm-planes-accept.md new file mode 100644 index 00000000..b77d55c5 --- /dev/null +++ b/.changeset/calm-planes-accept.md @@ -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 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0579f4bc..19440b03 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ebd55871..445d9d6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/gitops.config.ts b/gitops.config.ts index 8a2252f1..d47cba7d 100644 --- a/gitops.config.ts +++ b/gitops.config.ts @@ -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', diff --git a/src/lib/ci_reconcile.ts b/src/lib/ci_reconcile.ts new file mode 100644 index 00000000..388dfe29 --- /dev/null +++ b/src/lib/ci_reconcile.ts @@ -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): Array => { + const drift: Array = []; + 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')); +}; diff --git a/src/lib/gitops_config.ts b/src/lib/gitops_config.ts index ebe5b828..295e6a77 100644 --- a/src/lib/gitops_config.ts +++ b/src/lib/gitops_config.ts @@ -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. @@ -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 => ({ @@ -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, }; }; diff --git a/src/lib/gitops_validate.task.ts b/src/lib/gitops_validate.task.ts index 78585646..de9c9367 100644 --- a/src/lib/gitops_validate.task.ts +++ b/src/lib/gitops_validate.task.ts @@ -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({ @@ -86,7 +87,7 @@ export const task: Task = { results.push({ command: 'gitops_analyze', - success: true, + success: errors === 0, warnings, errors, duration: analyze_duration, @@ -129,7 +130,7 @@ export const task: Task = { results.push({ command: 'gitops_plan', - success: true, + success: errors === 0, warnings, errors, duration: plan_duration, @@ -202,6 +203,56 @@ export const task: Task = { 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); @@ -247,10 +298,11 @@ export const task: Task = { 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'); } }, diff --git a/src/test/ci_reconcile.test.ts b/src/test/ci_reconcile.test.ts new file mode 100644 index 00000000..733f442c --- /dev/null +++ b/src/test/ci_reconcile.test.ts @@ -0,0 +1,137 @@ +import {assert, describe, test} from 'vitest'; +import {join} from 'node:path'; +import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; + +import {reconcile_ci, repo_has_workflows} from '$lib/ci_reconcile.js'; + +describe('reconcile_ci', () => { + test('flags ci=true with no workflows as missing_ci', () => { + const drift = reconcile_ci([ + { + repo_url: 'https://github.com/x/a', + ci: true, + has_workflows: false, + checkable: true, + archived: false, + }, + ]); + assert.equal(drift.length, 1); + const [first] = drift; + assert(first); + assert.equal(first.kind, 'missing_ci'); + }); + + test('flags ci=false with workflows as stray_ci', () => { + const drift = reconcile_ci([ + { + repo_url: 'https://github.com/x/b', + ci: false, + has_workflows: true, + checkable: true, + archived: false, + }, + ]); + assert.equal(drift.length, 1); + const [first] = drift; + assert(first); + assert.equal(first.kind, 'stray_ci'); + }); + + test('no drift when declaration matches reality', () => { + const drift = reconcile_ci([ + { + repo_url: 'https://github.com/x/c', + ci: true, + has_workflows: true, + checkable: true, + archived: false, + }, + { + repo_url: 'https://github.com/x/d', + ci: false, + has_workflows: false, + checkable: true, + archived: false, + }, + ]); + assert.equal(drift.length, 0); + }); + + test('skips repos that are not checked out locally', () => { + const drift = reconcile_ci([ + { + repo_url: 'https://github.com/x/e', + ci: true, + has_workflows: false, + checkable: false, + archived: false, + }, + ]); + assert.equal(drift.length, 0); + }); + + test('skips archived repos even when they would otherwise drift', () => { + const drift = reconcile_ci([ + { + repo_url: 'https://github.com/x/f', + ci: true, + has_workflows: false, + checkable: true, + archived: true, + }, + { + repo_url: 'https://github.com/x/g', + ci: false, + has_workflows: true, + checkable: true, + archived: true, + }, + ]); + assert.equal(drift.length, 0); + }); +}); + +describe('repo_has_workflows', () => { + test('returns false when there is no `.github/workflows` directory', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ci_reconcile_test_')); + try { + assert.equal(repo_has_workflows(dir), false); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); + + test('returns true when a `.yml` workflow is present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ci_reconcile_test_')); + try { + await mkdir(join(dir, '.github', 'workflows'), {recursive: true}); + await writeFile(join(dir, '.github', 'workflows', 'check.yml'), 'name: check'); + assert.equal(repo_has_workflows(dir), true); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); + + test('returns true when a `.yaml` workflow is present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ci_reconcile_test_')); + try { + await mkdir(join(dir, '.github', 'workflows'), {recursive: true}); + await writeFile(join(dir, '.github', 'workflows', 'deploy.yaml'), 'name: deploy'); + assert.equal(repo_has_workflows(dir), true); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); + + test('returns false when the workflows directory holds no `.yml`/`.yaml` files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ci_reconcile_test_')); + try { + await mkdir(join(dir, '.github', 'workflows'), {recursive: true}); + await writeFile(join(dir, '.github', 'workflows', 'README.md'), '# workflows'); + assert.equal(repo_has_workflows(dir), false); + } finally { + await rm(dir, {recursive: true, force: true}); + } + }); +}); diff --git a/src/test/fixtures/load_repo_fixtures.ts b/src/test/fixtures/load_repo_fixtures.ts index 63090e3c..bf388312 100644 --- a/src/test/fixtures/load_repo_fixtures.ts +++ b/src/test/fixtures/load_repo_fixtures.ts @@ -39,6 +39,9 @@ export const fixture_repo_to_local_repo = (repo_data: RepoFixtureData): LocalRep repo_url, repo_dir: null, branch: 'main', + visibility: 'public', + ci: true, + archived: false, }, }; diff --git a/src/test/gitops_config.test.ts b/src/test/gitops_config.test.ts new file mode 100644 index 00000000..23cba10f --- /dev/null +++ b/src/test/gitops_config.test.ts @@ -0,0 +1,157 @@ +import {assert, describe, test} from 'vitest'; + +import { + normalize_gitops_config, + create_empty_gitops_config, + type GitopsRepoConfig, + type RawGitopsRepoConfig, +} from '$lib/gitops_config.js'; +import type {Url} from '@fuzdev/fuz_util/url.js'; + +/** Normalizes a single raw repo entry and returns its parsed config. */ +const parse_one = (raw: Url | RawGitopsRepoConfig): GitopsRepoConfig => { + const {repos} = normalize_gitops_config({repos: [raw]}); + const [first] = repos; + assert(first); + return first; +}; + +describe('normalize_gitops_config', () => { + describe('top-level config', () => { + test('empty config falls back to defaults', () => { + const empty = create_empty_gitops_config(); + const config = normalize_gitops_config({}); + assert.deepEqual(config.repos, []); + assert.equal(config.repos_dir, empty.repos_dir); + }); + + test('preserves a provided repos_dir', () => { + const config = normalize_gitops_config({repos_dir: '/custom/repos'}); + assert.equal(config.repos_dir, '/custom/repos'); + }); + + test('undefined repos normalizes to an empty array', () => { + assert.deepEqual(normalize_gitops_config({repos: undefined}).repos, []); + }); + }); + + describe('string repo entries', () => { + test('applies all defaults', () => { + const repo = parse_one('https://github.com/fuzdev/fuz_ui'); + assert.deepEqual(repo, { + repo_url: 'https://github.com/fuzdev/fuz_ui', + repo_dir: null, + branch: 'main', + visibility: 'public', + ci: true, + archived: false, + }); + }); + }); + + describe('object repo entries', () => { + test('applies defaults for a minimal entry', () => { + const repo = parse_one({repo_url: 'https://github.com/fuzdev/fuz_ui'}); + assert.deepEqual(repo, { + repo_url: 'https://github.com/fuzdev/fuz_ui', + repo_dir: null, + branch: 'main', + visibility: 'public', + ci: true, + archived: false, + }); + }); + + test('strips a trailing `.git` from the repo_url', () => { + const repo = parse_one({repo_url: 'https://github.com/fuzdev/fuz_ui.git'}); + assert.equal(repo.repo_url, 'https://github.com/fuzdev/fuz_ui'); + }); + + test('preserves repo_dir and branch', () => { + const repo = parse_one({ + repo_url: 'https://github.com/fuzdev/fuz_ui', + repo_dir: 'some/dir', + branch: 'next', + }); + assert.equal(repo.repo_dir, 'some/dir'); + assert.equal(repo.branch, 'next'); + }); + + test('null repo_dir is preserved', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/fuz_ui', repo_dir: null}).repo_dir, + null, + ); + }); + }); + + describe('visibility', () => { + test('defaults to public', () => { + assert.equal(parse_one({repo_url: 'https://github.com/fuzdev/x'}).visibility, 'public'); + }); + + test('preserves an explicit private visibility', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', visibility: 'private'}).visibility, + 'private', + ); + }); + }); + + describe('ci derivation', () => { + test('public repos default ci to true', () => { + assert.equal(parse_one({repo_url: 'https://github.com/fuzdev/x'}).ci, true); + }); + + test('private repos default ci to false', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', visibility: 'private'}).ci, + false, + ); + }); + + test('explicit ci overrides the visibility-derived default', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', visibility: 'private', ci: true}).ci, + true, + ); + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', visibility: 'public', ci: false}).ci, + false, + ); + }); + }); + + describe('archived', () => { + test('defaults to false when not provided', () => { + assert.equal(parse_one({repo_url: 'https://github.com/fuzdev/x'}).archived, false); + }); + + test('preserves an explicit archived: true', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', archived: true}).archived, + true, + ); + }); + + test('preserves an explicit archived: false', () => { + assert.equal( + parse_one({repo_url: 'https://github.com/fuzdev/x', archived: false}).archived, + false, + ); + }); + }); + + test('normalizes multiple repos preserving order', () => { + const {repos} = normalize_gitops_config({ + repos: [ + 'https://github.com/fuzdev/a', + {repo_url: 'https://github.com/fuzdev/b', visibility: 'private'}, + ], + }); + assert.equal(repos.length, 2); + assert.equal(repos[0]?.repo_url, 'https://github.com/fuzdev/a'); + assert.equal(repos[1]?.repo_url, 'https://github.com/fuzdev/b'); + assert.equal(repos[1]?.visibility, 'private'); + }); +}); diff --git a/src/test/local_repo.test.ts b/src/test/local_repo.test.ts index 6e1aa5c6..cb0481c2 100644 --- a/src/test/local_repo.test.ts +++ b/src/test/local_repo.test.ts @@ -14,6 +14,9 @@ const create_local_repo_path = (name: string = 'test-repo'): LocalRepoPath => ({ repo_url: `https://github.com/test/${name}`, repo_dir: null, branch: 'main', + visibility: 'public', + ci: true, + archived: false, }, }); diff --git a/src/test/test_helpers.ts b/src/test/test_helpers.ts index a3f7f419..a7cab1bf 100644 --- a/src/test/test_helpers.ts +++ b/src/test/test_helpers.ts @@ -72,6 +72,9 @@ export const create_mock_repo = (options: MockRepoOptions): LocalRepo => { repo_url: `https://github.com/test/${name}`, repo_dir: null, branch: 'main', + visibility: 'public', + ci: true, + archived: false, }, dependencies: new Map(Object.entries(deps)), dev_dependencies: new Map(Object.entries(dev_deps)),