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
5 changes: 5 additions & 0 deletions .changeset/bright-cycles-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ankhorage/devtools': minor
---

Add a centrally managed PKGViz cyclic-dependency CI audit for repositories with source trees, including a pinned tool invocation and diagnostic audit artifact upload.
44 changes: 44 additions & 0 deletions src/policy/resolvePkgvizAuditPolicyAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';

import { afterEach, describe, expect, test } from 'bun:test';

import { resolvePkgvizAuditPolicyAsync } from './resolvePkgvizAuditPolicyAsync.js';

const temporaryDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});

describe('PKGViz audit policy', () => {
test('pins the blocking cycle audit for repositories with source', async () => {
const target = await createTarget();
await mkdir(join(target, 'src'));

const policy = await resolvePkgvizAuditPolicyAsync(target);

expect(policy).toEqual({
artifactName: 'pkgviz-audit',
artifactPath: 'pkgviz-audit.json',
command: 'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block',
});
});

test('does not enable PKGViz for repositories without a source tree', async () => {
const target = await createTarget();

const policy = await resolvePkgvizAuditPolicyAsync(target);

expect(policy).toBeUndefined();
});
});

/*** Create an isolated managed-repository fixture. */
async function createTarget(): Promise<string> {
const target = await mkdtemp('/tmp/devtools-pkgviz-audit-');
temporaryDirectories.push(target);
return target;
}
36 changes: 36 additions & 0 deletions src/policy/resolvePkgvizAuditPolicyAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { stat } from 'node:fs/promises';
import { join } from 'node:path';

/*** Resolve the centrally pinned PKGViz CI audit for repositories with analyzable source. */
export async function resolvePkgvizAuditPolicyAsync(
targetDirectory: string,
): Promise<PkgvizAuditPolicy | undefined> {
if (!(await hasSourceDirectoryAsync(targetDirectory))) return undefined;

return {
artifactName: 'pkgviz-audit',
artifactPath: 'pkgviz-audit.json',
command: 'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block',
};
}

interface PkgvizAuditPolicy {
readonly artifactName: string;
readonly artifactPath: string;
readonly command: string;
}

/*** Detect whether the managed repository has a source tree that PKGViz can inspect. */
async function hasSourceDirectoryAsync(targetDirectory: string): Promise<boolean> {
try {
return (await stat(join(targetDirectory, 'src'))).isDirectory();
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return false;
throw error;
}
}

/*** Narrow filesystem failures to Node errors with stable error codes. */
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
2 changes: 2 additions & 0 deletions src/tools/workflows/files/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ jobs:
else
echo "No changeset:status script found; skipping."
fi

# __ANKH_PKGVIZ_AUDIT_STEPS__
25 changes: 25 additions & 0 deletions src/tools/workflows/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ test('managed release synchronizes main before build and recovers a matching his
expect(release).toContain('echo "versioned=false" >> "$GITHUB_OUTPUT"');
});

describe('managed PKGViz audit', () => {
test('renders the pinned blocking audit and diagnostic artifact for source repositories', async () => {
const target = await createWorkflowTarget();
await mkdir(join(target, 'src'));

const ci = await workflowManagedFiles[0].render?.(target);

expect(ci).toContain(
'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block',
);
expect(ci).toContain("if: always() && hashFiles('pkgviz-audit.json') != ''");
expect(ci).toContain('name: pkgviz-audit');
expect(ci).toContain('path: pkgviz-audit.json');
});

test('omits the audit from repositories without analyzable source', async () => {
const target = await createWorkflowTarget();

const ci = await workflowManagedFiles[0].render?.(target);

expect(ci).not.toContain('Enforce PKGViz cyclic-dependencies rule');
expect(ci).not.toContain('pkgviz-audit.json');
});
});

describe('managed CI Changesets contract', () => {
test('keeps the missing-Changeset guard strict for every ordinary pull request', async () => {
const ci = await workflowManagedFiles[0].render?.('.');
Expand Down
2 changes: 2 additions & 0 deletions src/tools/workflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path';
import { resolveApmReleaseCommandAsync } from '../../features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.js';
import { resolveStructureReleaseCommandAsync } from '../../features/structure-descriptor-generation/adapters/outbound/resolveStructureReleaseCommandAsync.js';
import { bunRuntimePolicy, nodeRuntimePolicy } from '../../policy/bunRuntimePolicy.js';
import { resolvePkgvizAuditPolicyAsync } from '../../policy/resolvePkgvizAuditPolicyAsync.js';
import type { ManagedFileDefinition } from '../shared/managedFiles.js';
import { readCurrentDoctorVersion } from './readCurrentDoctorVersion.js';
import { renderRenovateWorkflowAsync } from './renderRenovateWorkflowAsync.js';
Expand Down Expand Up @@ -43,6 +44,7 @@ function createWorkflowDefinition(relativePath: string, sourcePath: string): Man
bunVersion: bunRuntimePolicy.version,
doctorVersion: readCurrentDoctorVersion(),
nodeVersion: nodeRuntimePolicy.setupVersion,
pkgvizAudit: await resolvePkgvizAuditPolicyAsync(targetDirectory),
}),
};
}
Expand Down
24 changes: 24 additions & 0 deletions src/tools/workflows/renderWorkflowAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export interface WorkflowPolicy {
readonly doctorVersion: string;
readonly nodeVersion: string;
readonly apmReleaseCommand?: string;
readonly pkgvizAudit?: {
readonly artifactName: string;
readonly artifactPath: string;
readonly command: string;
};
readonly structureReleaseCommand?: string;
}

Expand All @@ -15,6 +20,7 @@ export async function renderWorkflowAsync(sourceUrl: URL, policy: WorkflowPolicy
const template = await readFile(sourceUrl, 'utf8');
return template
.replaceAll(BUN_VERSION_TOKEN, policy.bunVersion)
.replaceAll(PKGVIZ_AUDIT_STEPS_TOKEN, renderPkgvizAuditSteps(policy))
.replaceAll(CHANGESETS_PUBLISH_COMMAND_TOKEN, changesetsPolicy.workflowCommands.publish)
.replaceAll(CHANGESETS_STATUS_COMMAND_TOKEN, changesetsPolicy.workflowCommands.status)
.replaceAll(CHANGESETS_VERSION_COMMAND_TOKEN, changesetsPolicy.workflowCommands.version)
Expand All @@ -36,3 +42,21 @@ const CHANGESETS_STATUS_COMMAND_TOKEN = '__ANKH_CHANGESETS_STATUS_COMMAND__';
const CHANGESETS_VERSION_COMMAND_TOKEN = '__ANKH_CHANGESETS_VERSION_COMMAND__';
const DOCTOR_VERSION_TOKEN = '__ANKH_DOCTOR_VERSION__';
const NODE_VERSION_TOKEN = '__ANKH_NODE_VERSION__';

/*** Render the centrally managed PKGViz audit steps only for applicable repositories. */
function renderPkgvizAuditSteps(policy: WorkflowPolicy): string {
if (policy.pkgvizAudit === undefined) return '';

return ` - name: Enforce PKGViz cyclic-dependencies rule
run: ${policy.pkgvizAudit.command}

- name: Upload PKGViz audit
if: always() && hashFiles('${policy.pkgvizAudit.artifactPath}') != ''
uses: actions/upload-artifact@v4
with:
name: ${policy.pkgvizAudit.artifactName}
path: ${policy.pkgvizAudit.artifactPath}
`;
}

const PKGVIZ_AUDIT_STEPS_TOKEN = ' # __ANKH_PKGVIZ_AUDIT_STEPS__';
Loading