Skip to content
Open
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
22 changes: 22 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The `redhat-developer/rhdh-plugins` repository is designed as a collaborative sp
- [Forking the Repository](#forking-the-repository)
- [Developing Plugins in Workspaces](#developing-plugins-in-workspaces)
- [Coding Guidelines](#coding-guidelines)
- [yarn fix](#yarn-fix)
- [Versioning](#versioning)
- [Creating Changesets](#creating-changesets)
- [Release](#release)
Expand Down Expand Up @@ -80,6 +81,27 @@ For consistency across the monorepo, we suggest following the same Yarn setup as

All code is formatted with `prettier` using the configuration in the repo. If possible we recommend configuring your editor to format automatically, but you can also use the `yarn prettier --write <file>` command to format files.

### yarn fix

From a workspace root (`workspaces/<name>`), run `yarn fix` before you consider the work done. Do not run it from the repository root; each workspace has its own install and its own `yarn fix`.

The command delegates to `backstage-cli repo fix`, then runs additional fixers when they are available. Execution order is defined in `scripts/workspace-fix.mjs`:

1. `backstage-cli repo fix` (pass `--publish` with `rhdhFix.publish` or `--publish`)
2. `sort-package-json` (skipped unless the workspace depends on it)
3. `backstage-cli repo lint --fix`
4. `markdownlint --fix` (skipped unless the workspace depends on it)
5. `prettier --write .` (always last among formatters)
6. `knip --fix` (opt-in only: `rhdhFix.knip` or `--knip`)

`yarn fix` exits 0 when every run fixer succeeds, even if files changed. It exits non-zero if a fixer fails. Missing optional fixers are skipped, not treated as failures.

`yarn fix --check` runs only `backstage-cli repo fix --check` (and `--publish` when configured). CI uses this mode; lint, prettier, and publish validation run as separate workflow steps.

Memory-heavy fixers run with `NODE_OPTIONS=--max-old-space-size=8192`, matching CI. Workspaces that build dynamic plugin bundles should list `dist-dynamic` and `dist-scalprum` in `.eslintignore` and `.prettierignore` so `repo lint --fix` and `prettier --write` do not traverse generated output. If a workspace still runs out of memory during `repo lint --fix`, set a higher value in `rhdhFix.nodeOptions` in that workspace's `package.json`.

To add a new fixer, change `scripts/workspace-fix.mjs` only. Workspace `package.json` files should keep `"fix": "node ../../scripts/workspace-fix.mjs"`. The `noop` workspace is the exception and stays a no-op.

## Versioning

For the versioning all packages in this repository are following the semantic versioning standard enforced through Changesets. This is the same approach as in the “backstage/community-plugins" repository. If this is your first time working with Changesets checkout [this documentation](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets) or read a quick summary below.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ Contributions are welcome! To contribute a plugin, please follow the guidelines
## Plugins Workflow

The `rhdh-plugins` repository is organized into multiple workspaces, with each workspace containing a plugin or a set of related plugins. Each workspace operates independently, with its own release cycle and dependencies managed via npm. When a new changeset is added (each workspace has its own `.changesets` directory), a "Version packages ($workspace_name)" PR is automatically generated. Merging this PR triggers the release of all plugins in the workspace and updates the corresponding `CHANGELOG` files.

## yarn fix

From a workspace directory (`workspaces/<name>`), run `yarn fix` to auto-correct fixable package, lint, and format issues. The pipeline is defined once in `scripts/workspace-fix.mjs`. See [CONTRIBUTING.md](CONTRIBUTING.md#yarn-fix) for the fixer order and how to add a new fixer.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"create-workspace": "rhdh-repo-tools workspace create",
"test:workspace-fix": "node --test scripts/workspace-fix.test.mjs",
"postinstall": "husky",
"prettier:check": "prettier --check .",
"prettier:fix": "prettier --write ."
Expand Down
331 changes: 331 additions & 0 deletions scripts/workspace-fix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
/*
Comment thread
ciiay marked this conversation as resolved.
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

export const REPO_ROOT_PACKAGE_NAME = '@redhat-developer/rhdh-plugins';

// Match .github/workflows/ci.yml so local yarn fix behaves like CI.
export const DEFAULT_NODE_OPTIONS = '--max-old-space-size=8192';

const MEMORY_HEAVY_STEPS = new Set([
'repo-fix',
'lint-fix',
'prettier',
'knip',
]);

const MARKDOWNLINT_PACKAGES = [
'markdownlint-cli2',
'markdownlint-cli',
'markdownlint',
];

/**
* Deterministic fixer order. Add new fixers here — workspace package.json
* scripts should keep pointing at this file.
*
* 1. backstage-cli repo fix (package.json exports / metadata)
* 2. sort-package-json (optional; skipped unless installed)
* 3. backstage-cli repo lint --fix
* 4. markdownlint --fix (optional; skipped unless installed)
* 5. prettier --write (last formatter so eslint and prettier do not fight)
* 6. knip --fix (opt-in only; skipped unless rhdhFix.knip or --knip)
*/
export const FIXER_ORDER = [
'repo-fix',
'sort-package-json',
'lint-fix',
'markdownlint',
'prettier',
'knip',
];

export function parseArgs(argv) {
const extra = [];
const flags = { publish: false, knip: false, check: false, help: false };
for (const arg of argv) {
if (arg === '--publish') {
flags.publish = true;
} else if (arg === '--knip') {
flags.knip = true;
} else if (arg === '--check') {
flags.check = true;
} else if (arg === '--help' || arg === '-h') {
flags.help = true;
} else if (arg.startsWith('-')) {
throw Object.assign(
new Error(`Unknown flag '${arg}'. Use --check, --publish, or --knip.`),
{ exitCode: 1 },
);
} else {
extra.push(arg);
}
}
return { flags, extra };
}

export function readPackageJson(cwd) {
const pkgPath = resolve(cwd, 'package.json');
if (!existsSync(pkgPath)) {
throw Object.assign(
new Error(
`No package.json in ${cwd}. Run yarn fix from a workspace root (workspaces/<name>).`,
),
{ exitCode: 1 },
);
}
return JSON.parse(readFileSync(pkgPath, 'utf8'));
}

export function assertWorkspaceRoot(pkg) {
if (pkg.name === REPO_ROOT_PACKAGE_NAME) {
throw Object.assign(
new Error(
'yarn fix is per workspace. cd into workspaces/<name> and run yarn fix there.',
),
{ exitCode: 1 },
);
}
if (!pkg.workspaces) {
throw Object.assign(
new Error(
'yarn fix must run from a workspace root that declares a workspaces field.',
),
{ exitCode: 1 },
);
}
}

export function resolveConfig(pkg, flags) {
const fromPkg = pkg.rhdhFix ?? {};
return {
check: Boolean(flags.check),
publish: Boolean(fromPkg.publish || flags.publish),
knip: Boolean(fromPkg.knip || flags.knip),
nodeOptions: fromPkg.nodeOptions,
};
}

export function mergeNodeOptions(existing, additional, options = {}) {
const { overrideHeapLimit = false } = options;
if (!additional) {
return existing;
}
if (!existing) {
return additional;
}
if (existing.includes('max-old-space-size')) {
if (overrideHeapLimit) {
const replacement = additional.match(/--max-old-space-size=\d+/)?.[0];
if (replacement) {
return existing.replace(/--max-old-space-size=\d+/, replacement);
}
}
return existing;
}
return `${existing} ${additional}`.trim();
Comment thread
ciiay marked this conversation as resolved.
}

export function resolveSpawnEnv(step, config, baseEnv = process.env) {
const extra =
config.nodeOptions ??
(MEMORY_HEAVY_STEPS.has(step.id) ? DEFAULT_NODE_OPTIONS : undefined);
if (!extra) {
Comment thread
ciiay marked this conversation as resolved.
return baseEnv;
}
return {
...baseEnv,
NODE_OPTIONS: mergeNodeOptions(baseEnv.NODE_OPTIONS, extra, {
overrideHeapLimit: Boolean(config.nodeOptions),
}),
};
}

export function detectTools(pkg) {
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const markdownlintPkg = MARKDOWNLINT_PACKAGES.find(name => name in deps);
return {
backstageCli: '@backstage/cli' in deps,
prettier: 'prettier' in deps,
sortPackageJson: 'sort-package-json' in deps,
markdownlint: markdownlintPkg,
knip: 'knip' in deps,
};
}

function markdownlintArgs(packageName) {
if (packageName === 'markdownlint-cli2') {
return ['exec', 'markdownlint-cli2', '--fix'];
}
return ['exec', 'markdownlint', '--fix', '**/*.md'];
}

function repoFixArgs(config) {
return [
'backstage-cli',
'repo',
'fix',
...(config.check ? ['--check'] : []),
...(config.publish ? ['--publish'] : []),
];
}

export function buildSteps({ tools, config }) {
const repoFixStep = {
id: 'repo-fix',
required: true,
available: tools.backstageCli,
command: 'yarn',
args: repoFixArgs(config),
};

if (config.check) {
return [repoFixStep];
}

return [
repoFixStep,
{
id: 'sort-package-json',
Comment thread
ciiay marked this conversation as resolved.
required: false,
available: Boolean(tools.sortPackageJson),
command: 'yarn',
args: ['exec', 'sort-package-json', 'package.json'],
},
{
Comment thread
ciiay marked this conversation as resolved.
id: 'lint-fix',
required: true,
available: tools.backstageCli,
command: 'yarn',
args: ['backstage-cli', 'repo', 'lint', '--fix'],
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] style/conventions

resolveSpawnEnv applies rhdhFix.nodeOptions to ALL pipeline steps (including lightweight ones like sort-package-json) due to the nullish coalescing operator taking precedence over the MEMORY_HEAVY_STEPS check. This is harmless but inconsistent with the CONTRIBUTING.md documentation that frames nodeOptions as a memory-relief override for heavy fixers.

Suggested fix: Consider gating rhdhFix.nodeOptions behind the MEMORY_HEAVY_STEPS check as well, or update the documentation to clarify it applies to all steps.

{
id: 'markdownlint',
required: false,
available: Boolean(tools.markdownlint),
command: 'yarn',
args: markdownlintArgs(tools.markdownlint),
},
{
id: 'prettier',
required: false,
available: Boolean(tools.prettier),
command: 'yarn',
args: ['prettier', '--write', '.'],
},
{
id: 'knip',
required: false,
available: Boolean(tools.knip) && config.knip,
skipReason: config.knip
? 'knip is not installed'
: 'knip --fix is opt-in (set rhdhFix.knip or pass --knip)',
command: 'yarn',
args: ['knip', '--fix'],
},
];
}

export async function runPipeline(steps, { run, log }) {
for (const step of steps) {
if (!step.available) {
if (step.required) {
throw Object.assign(
new Error(`Required fixer '${step.id}' is not available`),
{ exitCode: 1 },
);
}
log(`skip ${step.id}: ${step.skipReason ?? 'not installed'}`);
continue;
}

log(`run ${step.id}: ${step.command} ${step.args.join(' ')}`);
const code = await run(step);
if (code !== 0) {
throw Object.assign(
new Error(`Fixer '${step.id}' failed with exit code ${code}`),
{ exitCode: code },
);
}
}
}

function spawnStep(step, cwd, config) {
return new Promise(resolvePromise => {
const child = spawn(step.command, step.args, {
cwd,
stdio: 'inherit',
env: resolveSpawnEnv(step, config),
});
child.on('error', () => resolvePromise(1));
child.on('close', code => resolvePromise(code ?? 1));
});
}

export function helpText() {
return [
'Usage: yarn fix [--check] [--publish] [--knip]',
'',
'Run from a workspace root (workspaces/<name>).',
'Fixer order is defined in scripts/workspace-fix.mjs.',
'',
' --check Run backstage-cli repo fix --check only (matches CI)',
' --publish Pass --publish to backstage-cli repo fix',
' --knip Run knip --fix (off by default)',
'',
'Exit 0 when every run fixer succeeds, even if files changed.',
'Exit non-zero when a fixer fails. Missing optional fixers are skipped.',
].join('\n');
}

export async function main(argv, cwd, io = console) {
const { flags } = parseArgs(argv);
if (flags.help) {
io.log(helpText());
return;
}

const pkg = readPackageJson(cwd);
assertWorkspaceRoot(pkg);
const config = resolveConfig(pkg, flags);
const tools = detectTools(pkg);
const steps = buildSteps({ tools, config });
await runPipeline(steps, {
log: msg => io.log(msg),
run: step => spawnStep(step, cwd, config),
});
}

function isMainModule() {
const entry = process.argv[1];
if (!entry) {
return false;
}
return import.meta.url === pathToFileURL(resolve(entry)).href;
}

if (isMainModule()) {
try {
await main(process.argv.slice(2), process.cwd());
} catch (error) {
console.error(error.message);
process.exit(error.exitCode ?? 1);
}
}
Loading
Loading