Files
oxc-project__oxc/.github/scripts/update-rule-versions.mts
Old Autumn fe23965191 feat(linter/release): automate oxlint rule version updates (#21392)
## Summary

This PR covers the release automation follow-up from #19890.

It replaces the earlier Rust-based prototype with a small Node script,
`.github/scripts/update-rule-versions.js`, that scans
`declare_oxc_lint!` declarations under `crates/oxc_linter/src/rules/**`
and:

- rewrites stable `version = "next"` entries to the actual oxlint
release version during release prep
- leaves `nursery` rules on `version = "next"`
- reuses the same script during release CI and fails if rerunning it
would still rewrite tracked rule files, so no stable `version = "next"`
remains at release time
- prints a simple change report
- supports `--dry-run` for local verification

The implementation also now hardens a few failure modes that came up
during review:

- comment-only lines inside `declare_oxc_lint!` blocks are ignored when
parsing metadata
- trailing `// ...` and single-line `/* ... */` comments on metadata
lines are handled correctly
- multi-line block comments on metadata lines and macro terminators are
handled correctly
- doc examples inside the macro block that mention `version = "next"`
are not misclassified as invalid matches
- symlinked entries under the lint rules tree are rejected instead of
being silently skipped
- the script still fails if `version = "next"` is found outside a
`declare_oxc_lint!` block

Workflow changes:

- `.github/workflows/prepare_release_apps.yml` sets up Node and runs the
rewrite script after `cargo release-oxc update`
- `.github/workflows/release_apps.yml` sets up Node, reruns the script
with the released oxlint version, and then checks `git diff --exit-code
-- crates/oxc_linter/src/rules`

## Testing

```bash
node --test .github/scripts/update-rule-versions.test.js
node .github/scripts/update-rule-versions.js --root . --release-version 1.61.0 --dry-run
cargo check -p oxc_linter --locked
```

## Notes

This PR intentionally does not include the docs/website wiring from
#19890.
That should stay in a follow-up PR since it touches a different data
flow and also involves `oxc-project/website#937`.

## AI Usage Disclosure

I used AI assistance to help inspect the release workflows, compare the
implementation direction against the maintainer feedback, and draft the
initial automation changes. I manually reviewed the final
implementation, added the regression tests for the workflow guard and
parser edge cases above, ran the checks listed here, and remain
responsible for the final code and submission.

---------

Co-authored-by: Cam McHenry <camchenry@users.noreply.github.com>
Co-authored-by: Cameron <cameron.clark@hey.com>
2026-04-20 17:53:52 +01:00

90 lines
2.9 KiB
TypeScript

#!/usr/bin/env node
/* eslint-disable no-console */
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
const DEFAULT_RULES_ROOT = path.join("crates", "oxc_linter", "src", "rules");
function analyzeRuleFile(source: string, releaseVersion: string): string {
return source.replace(/declare_oxc_lint!\(([\s\S]*?\n)\s*\);/g, (match, body: string) => {
// Strip leading doc comments, then extract comma-delimited fields: name, plugin, category
const stripped = body.replace(/^(?:\s*\/\/\/.*\n)*/, "");
const fields = [...stripped.matchAll(/(\w+)\s*,/g)];
if (fields.length < 3) return match;
const category = fields[2][1];
if (category === "nursery") return match;
if (!/version\s*=\s*"next"/.test(body)) return match;
return match.replace(/(version\s*=\s*)"next"/, `$1"${releaseVersion}"`);
});
}
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
"release-version": { type: "string", short: "r" },
root: { type: "string", short: "C", default: process.cwd() },
write: { type: "boolean", short: "w", default: false },
help: { type: "boolean", short: "h" },
},
strict: true,
});
const releaseVersion = values["release-version"];
const { root, write } = values;
if (values.help) {
console.log(`Usage:
node .github/scripts/update-rule-versions.mts --release-version <x.y.z> [--root <path>] [--write]
Options:
--release-version, -r Version to replace \`version = "next"\` with
--root, -C Repository root (defaults to current working directory)
--write, -w Write changes to files (default: dry-run)
--help, -h Show this help
`);
process.exit(0);
}
if (!releaseVersion) {
throw new Error("missing required `--release-version <x.y.z>`");
}
if (!/^\d+\.\d+\.\d+$/.test(releaseVersion)) {
throw new Error(`release version must be x.y.z, got \`${releaseVersion}\``);
}
const dryRun = !write;
const repoRoot = path.resolve(root);
const rulesRoot = path.join(repoRoot, DEFAULT_RULES_ROOT);
if (!existsSync(rulesRoot)) {
throw new Error(`rules root does not exist: ${rulesRoot}`);
}
let updatedCount = 0;
const ruleFiles = readdirSync(rulesRoot, { recursive: true })
.filter((f) => typeof f === "string" && f.endsWith(".rs"))
.map((f) => path.join(rulesRoot, String(f)))
.sort();
for (const filePath of ruleFiles) {
const source = readFileSync(filePath, "utf8");
const updatedSource = analyzeRuleFile(source, releaseVersion);
if (updatedSource === source) continue;
const relativeFile = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
console.log(
`${dryRun ? "Would update" : "Updated"} ${relativeFile}: version = "next" -> version = "${releaseVersion}"`,
);
updatedCount++;
if (!dryRun) {
writeFileSync(filePath, updatedSource);
}
}
console.log(`${updatedCount} rule(s) ${dryRun ? "would be updated" : "updated"}.`);