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
7 changes: 3 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

159 changes: 68 additions & 91 deletions release/publishing-contract/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,107 +3,79 @@ import { join, relative, resolve, sep } from 'node:path'
import process from 'node:process'
import { z } from 'zod'

type Workspace =
| { directory: string; publication: 'private' }
| { directory: string; publication: 'public'; violations: readonly string[] }

function validatePublishingContract(workspaces: Workspace[]): string[] {
return workspaces.flatMap((workspace) => {
if (workspace.publication === 'private') return []
if (!/^packages\/[^/]+$/.test(workspace.directory)) {
return [
`${workspace.directory}: only non-private direct children of packages/ may be published`,
]
}
return workspace.violations.map(
(violation) => `${workspace.directory}: ${violation}`
)
})
}

/** Every manifest, publishable or not, must at least say whether it is private. */
const admissionSchema = z.object({ private: z.boolean().optional() })

/**
* The publication contract for a package admitted under `packages/`. The
* schema describes the shape; `diagnostic` phrases each failing rule.
* The publication contract for a package admitted under `packages/`. Each
* rule carries one diagnostic, repeated on every node of the rule's shape so
* a missing key and a malformed value read the same.
*/
function publishableManifestSchema(directory: string): z.ZodType {
const expectedName = `@dphonys/${directory.slice('packages/'.length)}`
const version = 'version must be a valid semantic version'
const engines = 'engines.node must be a non-empty string'
const repository = `repository must be an object with type git, url identifying DPHonys/dph-nuxt-stuff, and directory ${directory}`
const files = 'files must declare distribution-only contents'
const main = 'main must declare a distribution runtime entry point'
const typesVersions = 'typesVersions must declare the public type entry point'
const exports = 'exports must declare the public package entry point'
const publishConfig = 'publishConfig.access must be public'
const scripts = 'scripts.prepack must run the package build'

return z.object({
name: z.literal(expectedName),
version: z.string().refine(isSemanticVersion),
license: nonEmptyString(),
engines: z.object({ node: nonEmptyString() }),
repository: z.object({
type: z.literal('git'),
url: z.literal('git+https://github.com/DPHonys/dph-nuxt-stuff.git'),
directory: z.literal(directory),
}),
name: z.literal(expectedName, `name must be ${expectedName}`),
version: z.string(version).refine(isSemanticVersion, version),
license: nonEmptyString('license must be a non-empty string'),
engines: z.object({ node: nonEmptyString(engines) }, engines),
repository: z.object(
{
type: z.literal('git', 'repository.type must be git'),
url: z.literal(
'git+https://github.com/DPHonys/dph-nuxt-stuff.git',
'repository.url must identify DPHonys/dph-nuxt-stuff'
),
directory: z.literal(
directory,
`repository.directory must be ${directory}`
),
},
repository
),
files: z
.array(z.string())
.array(z.string(files), files)
.refine(
(entries) =>
entries.length > 0 &&
entries.every((file) => file === 'dist' || file.startsWith('dist/'))
entries.every((file) => file === 'dist' || file.startsWith('dist/')),
files
),
main: z.string().startsWith('./dist/'),
main: z.string(main).startsWith('./dist/', main),
typesVersions: z
.record(z.string(), z.record(z.string(), z.array(z.string())))
.refine((entries) => Object.keys(entries).length > 0),
exports: z.object({ '.': z.looseObject({}) }),
publishConfig: z.object({ access: z.literal('public') }),
scripts: z.object({
prepack: z.string().regex(/\bbuild\b/),
}),
.record(
z.string(),
z.record(
z.string(),
z.array(z.string(typesVersions), typesVersions),
typesVersions
),
typesVersions
)
.refine((entries) => Object.keys(entries).length > 0, typesVersions),
exports: z.object({ '.': z.looseObject({}, exports) }, exports),
publishConfig: z.object(
{ access: z.literal('public', publishConfig) },
publishConfig
),
scripts: z.object(
{ prepack: z.string(scripts).regex(/\bbuild\b/, scripts) },
scripts
),
})
}

function nonEmptyString(): z.ZodType<string> {
return z.string().refine((value) => value.trim().length > 0)
}

/**
* One actionable diagnostic per contract rule, keyed by where in the manifest
* the schema issue arose. A missing key and a malformed value share a rule.
*/
function diagnostic(directory: string, issue: z.core.$ZodIssue): string {
const [field, subfield] = issue.path.map((segment) => String(segment))
switch (field) {
case 'name':
return `name must be @dphonys/${directory.slice('packages/'.length)}`
case 'version':
return 'version must be a valid semantic version'
case 'license':
return 'license must be a non-empty string'
case 'engines':
return 'engines.node must be a non-empty string'
case 'repository':
switch (subfield) {
case 'type':
return 'repository.type must be git'
case 'url':
return 'repository.url must identify DPHonys/dph-nuxt-stuff'
case 'directory':
return `repository.directory must be ${directory}`
default:
return `repository must be an object with type git, url identifying DPHonys/dph-nuxt-stuff, and directory ${directory}`
}
case 'files':
return 'files must declare distribution-only contents'
case 'main':
return 'main must declare a distribution runtime entry point'
case 'typesVersions':
return 'typesVersions must declare the public type entry point'
case 'exports':
return 'exports must declare the public package entry point'
case 'publishConfig':
return 'publishConfig.access must be public'
case 'scripts':
return 'scripts.prepack must run the package build'
default:
return `manifest ${issue.message}`
}
function nonEmptyString(message: string): z.ZodString {
return z.string(message).trim().min(1, message)
}

function isSemanticVersion(value: string): boolean {
Expand All @@ -117,7 +89,7 @@ function isSemanticVersion(value: string): boolean {
.some((identifier) => /^\d+$/.test(identifier) && /^0\d+/.test(identifier))
}

async function discoverWorkspaces(root: string): Promise<Workspace[]> {
async function discoverWorkspaces(root: string): Promise<string[][]> {
const patterns = await readWorkspacePatterns(root)
const manifestPaths = await findManifestPaths(root)
const workspacePaths = manifestPaths.filter((path) => {
Expand All @@ -137,16 +109,21 @@ async function discoverWorkspaces(root: string): Promise<Workspace[]> {
)
}

function readWorkspace(directory: string, manifestSource: string): Workspace {
/** The violations one workspace carries, each prefixed with its directory. */
function readWorkspace(directory: string, manifestSource: string): string[] {
const manifest = JSON.parse(manifestSource)
const admission = admissionSchema.parse(manifest)
if (admission.private === true) return { directory, publication: 'private' }
if (admissionSchema.parse(manifest).private === true) return []
if (!/^packages\/[^/]+$/.test(directory)) {
return [
`${directory}: only non-private direct children of packages/ may be published`,
]
}

const contract = publishableManifestSchema(directory).safeParse(manifest)
const violations = new Set(
contract.error?.issues.map((issue) => diagnostic(directory, issue))
contract.error?.issues.map((issue) => issue.message)
)
return { directory, publication: 'public', violations: [...violations] }
return [...violations].map((violation) => `${directory}: ${violation}`)
}

async function readWorkspacePatterns(root: string): Promise<string[]> {
Expand Down Expand Up @@ -235,7 +212,7 @@ const repositoryRoot = resolve(

try {
const workspaces = await discoverWorkspaces(repositoryRoot)
const violations = validatePublishingContract(workspaces)
const violations = workspaces.flat()
if (violations.length) {
for (const violation of violations) {
console.error(`Publishing contract violation: ${violation}`)
Expand Down
6 changes: 2 additions & 4 deletions release/publishing-contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
"scripts": {
"test": "vitest run tests"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@dphonys/test-utils": "workspace:*",
"vitest": "catalog:",
"yaml": "catalog:"
"yaml": "catalog:",
"zod": "catalog:"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ describe('first-release bootstrap contract', () => {
]).toSatisfy(
(indexes: number[]) =>
indexes.every((index) => index >= 0) &&
indexes.every((index, position) =>
indexes.slice(0, position).every((earlier) => earlier < index)
indexes.every(
(index, position) =>
position === 0 ||
index > (indexes[position - 1] ?? Number.NEGATIVE_INFINITY)
)
)
expect(guide).not.toContain('pnpm publish -r')
Expand Down
16 changes: 7 additions & 9 deletions release/publishing-contract/tests/publish-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ import { z } from 'zod'
const repositoryRoot = resolve(import.meta.dirname, '../../..')
const workflowPath = join(repositoryRoot, '.github', 'workflows', 'publish.yml')

const scalarSchema = z.union([z.string(), z.number(), z.boolean()])
/** Only the fields the suite reads are shaped; the rest are asserted wholesale. */
const stepSchema = z.object({
name: z.string().optional(),
uses: z.string().optional(),
if: z.string().optional(),
run: z.string().optional(),
with: z.record(z.string(), scalarSchema).optional(),
env: z.record(z.string(), scalarSchema).optional(),
with: z.unknown().optional(),
env: z.unknown().optional(),
})
const workflowSchema = z.object({
on: z.record(z.string(), z.looseObject({}).nullable()),
on: z.looseObject({}),
concurrency: z.object({
group: z.string(),
'cancel-in-progress': z.boolean(),
Expand All @@ -26,8 +25,8 @@ const workflowSchema = z.object({
jobs: z.record(
z.string(),
z.object({
environment: z.union([z.string(), z.looseObject({})]).optional(),
permissions: z.record(z.string(), z.string()).optional(),
environment: z.unknown().optional(),
permissions: z.unknown().optional(),
'runs-on': z.string().optional(),
steps: z.array(stepSchema),
})
Expand All @@ -40,7 +39,6 @@ const rootManifestSchema = z.object({
})

type WorkflowStep = z.infer<typeof stepSchema>
type PublishWorkflow = z.infer<typeof workflowSchema>

describe('trusted publication workflow', () => {
it('is manual-only, main-only, serialized, and least-privileged', async () => {
Expand Down Expand Up @@ -130,7 +128,7 @@ describe('trusted publication workflow', () => {
})
})

async function readWorkflow(): Promise<PublishWorkflow> {
async function readWorkflow() {
return workflowSchema.parse(parse(await readFile(workflowPath, 'utf8')))
}

Expand Down
6 changes: 3 additions & 3 deletions release/publishing-contract/tests/publishing-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ interface FixtureManifest {
repository?: { type?: string; url?: string; directory?: string }
files?: string[]
main?: string
typesVersions?: Record<string, Record<string, string[]>>
exports?: Record<string, Record<string, string>>
typesVersions?: { '*': { '.': string[] } }
exports?: { '.': { types: string; import: string } }
publishConfig?: { access?: string }
scripts?: Record<string, string>
scripts?: { prepack?: string }
}
type ManifestMutation = (manifest: FixtureManifest) => void

Expand Down
6 changes: 3 additions & 3 deletions release/release-preparation/tests/release-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ const packageManifestSchema = z.object({
type PackageManifest = z.infer<typeof packageManifestSchema>

const packedFileSchema = z.object({ filename: z.string() })
/** `pnpm pack --json` wraps the file in an array when run through a filter. */
const packOutputSchema = z.union([
packedFileSchema,
z.tuple([packedFileSchema], packedFileSchema),
z.tuple([packedFileSchema], packedFileSchema).transform(([first]) => first),
])

interface CommandResult {
Expand Down Expand Up @@ -433,8 +434,7 @@ async function packManifest(
'--pack-destination',
destination,
])
const result = packOutputSchema.parse(JSON.parse(packed.stdout))
const filename = Array.isArray(result) ? result[0].filename : result.filename
const { filename } = packOutputSchema.parse(JSON.parse(packed.stdout))
const tarball = resolve(packageDirectory(root, packageName), filename)
const extracted = await run(
'tar',
Expand Down
19 changes: 9 additions & 10 deletions scaffolder/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineCommand, runMain } from 'citty'
import process from 'node:process'
import { runScaffolder, type RunScaffolderOptions } from './index'
import { runScaffolder } from './index'
import { resolveScaffoldCliRequest } from './internal/non-interactive'
import { renderScaffoldOutcome } from './internal/outcome'

Expand Down Expand Up @@ -39,16 +39,15 @@ const scaffoldCommand = defineCommand({
}
process.once('SIGINT', interrupt)

const scaffolderOptions: RunScaffolderOptions = {
repositoryRoot: process.cwd(),
signal: controller.signal,
}
if (resolution.mode === 'non-interactive') {
scaffolderOptions.request = resolution.request
}

try {
const outcome = await runScaffolder(scaffolderOptions)
const outcome = await runScaffolder({
repositoryRoot: process.cwd(),
signal: controller.signal,
request:
resolution.mode === 'non-interactive'
? resolution.request
: undefined,
})
renderScaffoldOutcome(outcome)
process.exitCode = outcome.exitCode
} finally {
Expand Down
4 changes: 2 additions & 2 deletions scaffolder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface RunScaffolderOptions {
* request is validated up front and every failure exits non-zero instead of
* falling back to a prompt.
*/
request?: ScaffoldRequest
request?: ScaffoldRequest | undefined
}

/**
Expand All @@ -23,5 +23,5 @@ export async function runScaffolder(
options: RunScaffolderOptions
): Promise<ScaffoldOutcome> {
const { request, ...runOptions } = options
return createProductionScaffolder(request ? { request } : {}).run(runOptions)
return createProductionScaffolder({ request }).run(runOptions)
}
Loading
Loading