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/custom-yaml-jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pull-up/cli': patch
---

Add `type: custom` jobs to YAML configuration. Commands receive source files and execution context as JSON on stdin and write generated file contents to stdout.
5 changes: 5 additions & 0 deletions .changeset/deprecate-script-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pull-up/cli': patch
---

Warn when loading JavaScript or TypeScript configuration files while preserving their behavior. Add migration examples for built-in and custom jobs using YAML configuration.
124 changes: 123 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,49 @@ jobs:

Input patterns and the output path are relative to the repository root. Prefix an input pattern with `!` to exclude matching files. The output file is automatically excluded from the input.

### Custom Jobs

Use `type: custom` to generate a file with a shell command:

```yaml
jobs:
ownership:
type: custom
command: 'python3 scripts/ownership.py'
input: ['packages/**/ownership.yaml']
output: .github/CODEOWNERS
```

| Field | Type | Required | Default |
| --------- | ---------- | -------- | ------- |
| `type` | `custom` | Yes | |
| `command` | `string` | Yes | |
| `input` | `string[]` | No | `[]` |
| `output` | `string` | Yes | |

The command runs through `sh -c` from the root, so shell syntax such as pipes is supported. It receives a single JSON object on stdin:

```json
{
"sources": [
{
"path": "packages/core/ownership.yaml",
"contents": "owners: ['@core-team']\n"
}
],
"context": {
"rootDir": "/project",
"outputPath": "/project/.github/CODEOWNERS"
}
}
```

Source paths are relative to the repository root, and their contents are UTF-8 text. Both context paths are absolute.

Write the generated file contents to stdout and exit with code `0`. Trailing newlines are preserved. Use stderr for error messages and exit with a non-zero code on failure.

The command runs on every `pullup sync`, `pullup check`, and `pullup sync --dry-run`. pull-up writes the generated file only during `sync` without `--dry-run`.

### Editor Support

The package includes `schema.json` for autocomplete and validation in editors that support YAML Language Server. In VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add this line at the top of your config:
Expand All @@ -91,6 +134,10 @@ The path is relative to the YAML file. For Yarn Plug'n'Play or a remote schema,

### JavaScript and TypeScript Configuration

Starting in 0.0.8, JavaScript and TypeScript configuration files are deprecated. They continue to work, but pull-up prints one warning to stderr per command when it loads a `.js`, `.ts`, `.cjs`, or `.mjs` configuration file, including `.pulluprc` files with these extensions. See [Migration](#migration) to move to YAML.

The `package.json` `pullup` field and JSON/YAML `.pulluprc` configurations remain supported without this deprecation warning.

Use `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, or `pullup.config.mjs` for a JavaScript or TypeScript configuration:

```ts
Expand All @@ -103,7 +150,7 @@ The `package.json` `pullup` field and `.pulluprc` files are also supported. Thes

#### Custom Jobs

YAML currently supports built-in jobs only. To define a custom job, use `defineJob` in a JavaScript or TypeScript config:
Use `defineJob` to implement a custom transform in a JavaScript or TypeScript config:

```ts
import { defineConfig, defineJob } from '@pull-up/cli';
Expand All @@ -122,6 +169,81 @@ export default defineConfig([myJob()]);

Transform functions receive source files as `{ path, contents }` objects and a context containing `rootDir` and `outputPath`. They can return a string or a promise of a string.

### Migration

Replace a built-in job in `pullup.config.mjs`:

```js
import { defineConfig, codeownersJob } from '@pull-up/cli';

export default defineConfig(
codeownersJob({
input: ['**/CODEOWNERS', '!**/fixtures/**'],
output: '.github/CODEOWNERS',
}),
);
```

With `pullup.yml`:

```yaml
jobs:
codeowners:
type: codeowners
input: ['**/CODEOWNERS', '!**/fixtures/**']
output: .github/CODEOWNERS
```

For a custom callback, move the transform into a command. For example, replace this `pullup.config.mjs`:

```js
import { defineConfig, defineJob } from '@pull-up/cli';

const mergeConfigs = defineJob({
name: 'merge-configs',
input: ['packages/*/config.json'],
output: 'merged-config.json',
transform: (sources) => `${JSON.stringify(sources.map((source) => JSON.parse(source.contents)))}\n`,
});

export default defineConfig([mergeConfigs()]);
```

With `pullup.yml`:

```yaml
jobs:
merge-configs:
type: custom
command: 'node scripts/merge-configs.mjs'
input: ['packages/*/config.json']
output: merged-config.json
```

Create `scripts/merge-configs.mjs`:

```js
process.stdin.setEncoding('utf8');
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}

const { sources, context } = JSON.parse(input);

try {
const configs = sources.map((source) => JSON.parse(source.contents));
process.stdout.write(`${JSON.stringify(configs)}\n`);
} catch (error) {
console.error(`Failed to generate ${context.outputPath}: ${error.message}`);
process.exitCode = 1;
}
```

The script reads sources and context from JSON on stdin and writes the generated contents, including the final newline, to stdout. It does not write the output file itself. Input patterns and output paths remain relative to the repository root; the command also runs from that root. Your environment must provide the runtime used by the command, such as Node.js in this example.

After moving your jobs, delete the old JavaScript or TypeScript configuration file. Legacy configurations take precedence over `pullup.yaml` and `pullup.yml`, so leaving the old file in place prevents pull-up from loading your new YAML configuration. Run `pullup sync --dry-run` to preview the migrated jobs, then use `pullup sync` and `pullup check` as usual.

## Usage

### Sync
Expand Down
12 changes: 12 additions & 0 deletions fixtures/custom-job/ownership/codeowners.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
people:
alice:
github: alice-dev
bob:
github: bob-dev
carol:
github: carol-dev
teams:
platform:
members:
- bob
- alice
42 changes: 42 additions & 0 deletions fixtures/custom-job/ownership/generate-codeowners.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';

import { defaultLoaders } from 'cosmiconfig';

const { sources } = JSON.parse(readFileSync(0, 'utf8'));
const configs = sources.map(({ path: sourcePath, contents }) => ({
path: sourcePath,
config: defaultLoaders['.yaml'](sourcePath, contents),
}));
const registry = configs.find(({ path: sourcePath }) => sourcePath === 'codeowners.yaml').config;
const declarations = configs
.filter(({ path: sourcePath }) => sourcePath !== 'codeowners.yaml')
.flatMap(({ path: sourcePath, config }) => {
const directory = path.posix.dirname(sourcePath);
return [
{ directory, owners: config.owners },
...(config.rules ?? []).map((rule) => ({
directory: path.posix.join(directory, rule.path).replace(/\/$/, ''),
owners: rule.owners,
})),
];
});

declarations.sort((left, right) => {
const depth = left.directory.split('/').length - right.directory.split('/').length;
return depth || left.directory.localeCompare(right.directory);
});

const lines = declarations.map(({ directory, owners }) => {
const members = new Set([
...(owners.teams ?? []).flatMap((team) => registry.teams[team].members),
...(owners.people ?? []),
]);
const handles = Object.entries(registry.people)
.filter(([person]) => members.has(person))
.map(([, person]) => `@${person.github}`)
.join(' ');
return `/${directory}/ ${handles}\n`;
});

process.stdout.write(lines.join(''));
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
owners:
people:
- bob
14 changes: 14 additions & 0 deletions fixtures/custom-job/ownership/packages/core/ownership.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
owners:
teams:
- platform
people:
- alice
rules:
- path: feature/details/
owners:
people:
- bob
- path: feature/
owners:
people:
- carol
3 changes: 3 additions & 0 deletions fixtures/custom-job/ownership/packages/web/ownership.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
owners:
people:
- carol
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"dependencies": {
"clipanion": "^4.0.0-rc.4",
"cosmiconfig": "^9.0.0",
"execa": "^10.0.1",
"fast-glob": "^3.3.3",
"find-up": "^8.0.0",
"valibot": "^1.5.0"
Expand Down
108 changes: 79 additions & 29 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,88 @@
}
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"const": "codeowners"
},
"input": {
"type": "array",
"items": {
"type": "string"
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"const": "codeowners"
},
"input": {
"type": "array",
"items": {
"type": "string"
},
"description": "Input file paths for the job",
"examples": [
[
"**/CODEOWNERS",
"!**/fixtures/**"
]
]
},
"output": {
"type": "string",
"description": "Output file path for the job",
"examples": [
"CODEOWNERS",
".github/CODEOWNERS",
"docs/CODEOWNERS"
]
}
},
"description": "Input file paths for the job",
"examples": [
[
"**/CODEOWNERS",
"!**/fixtures/**"
]
]
"required": [
"type"
],
"additionalProperties": false
},
"output": {
"type": "string",
"description": "Output file path for the job",
"examples": [
"CODEOWNERS",
".github/CODEOWNERS",
"docs/CODEOWNERS"
]
{
"type": "object",
"properties": {
"type": {
"const": "custom"
},
"command": {
"type": "string",
"minLength": 1,
"description": "The command to run for the custom job",
"examples": [
"node scripts/generate-codeowners.js",
"python scripts/generate-codeowners.py"
]
},
"input": {
"type": "array",
"items": {
"type": "string"
},
"description": "Input file paths for the job",
"examples": [
[
"**/CODEOWNERS",
"!**/fixtures/**"
]
]
},
"output": {
"type": "string",
"minLength": 1,
"description": "Output file path for the job",
"examples": [
"CODEOWNERS",
".github/CODEOWNERS",
"docs/CODEOWNERS"
]
}
},
"required": [
"type",
"command",
"output"
],
"additionalProperties": false
}
},
"required": [
"type"
],
"additionalProperties": false
]
}
}
},
Expand Down
Loading
Loading