From 20a146428d99c092dd51617c9172c06cf2c4a099 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 9 May 2026 10:32:27 +0900 Subject: [PATCH] =?UTF-8?q?chore(release):=20Cargo=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release bump가 npm package와 Cargo workspace version을 함께 갱신하도록 자동화와 테스트를 확장합니다. Closes #85 --- .github/workflows/manual-release-bump.yml | 146 ++++++++++++++++-- Cargo.lock | 6 +- Cargo.toml | 2 +- scripts/bump-release-version.mjs | 171 +++++++++++++++++++++- test/bump-release-version.test.js | 127 +++++++++++----- 5 files changed, 399 insertions(+), 53 deletions(-) diff --git a/.github/workflows/manual-release-bump.yml b/.github/workflows/manual-release-bump.yml index e58cc5f..91588b9 100644 --- a/.github/workflows/manual-release-bump.yml +++ b/.github/workflows/manual-release-bump.yml @@ -84,10 +84,39 @@ jobs: - name: Verify changed files before package verification shell: bash run: | - changed_files=$(git diff --name-only) - if [ "$changed_files" != "package.json" ]; then + mapfile -t changed_files < <(git diff --name-only) + missing_files=() + unexpected_files=() + + for required_file in package.json Cargo.toml; do + found=false + for changed_file in "${changed_files[@]}"; do + if [ "$changed_file" = "$required_file" ]; then + found=true + break + fi + done + + if [ "$found" != "true" ]; then + missing_files+=("$required_file") + fi + done + + for changed_file in "${changed_files[@]}"; do + case "$changed_file" in + package.json|Cargo.toml|Cargo.lock) ;; + *) unexpected_files+=("$changed_file") ;; + esac + done + + if [ "${#missing_files[@]}" -gt 0 ] || [ "${#unexpected_files[@]}" -gt 0 ]; then echo "Unexpected changed files:" - printf '%s\n' "$changed_files" + printf 'Changed files:\n' + printf '%s\n' "${changed_files[@]}" + printf 'Missing required files:\n' + printf '%s\n' "${missing_files[@]}" + printf 'Unexpected files:\n' + printf '%s\n' "${unexpected_files[@]}" exit 1 fi @@ -97,10 +126,39 @@ jobs: - name: Verify changed files after package verification shell: bash run: | - changed_files=$(git diff --name-only) - if [ "$changed_files" != "package.json" ]; then + mapfile -t changed_files < <(git diff --name-only) + missing_files=() + unexpected_files=() + + for required_file in package.json Cargo.toml; do + found=false + for changed_file in "${changed_files[@]}"; do + if [ "$changed_file" = "$required_file" ]; then + found=true + break + fi + done + + if [ "$found" != "true" ]; then + missing_files+=("$required_file") + fi + done + + for changed_file in "${changed_files[@]}"; do + case "$changed_file" in + package.json|Cargo.toml|Cargo.lock) ;; + *) unexpected_files+=("$changed_file") ;; + esac + done + + if [ "${#missing_files[@]}" -gt 0 ] || [ "${#unexpected_files[@]}" -gt 0 ]; then echo "Unexpected changed files after verification:" - printf '%s\n' "$changed_files" + printf 'Changed files:\n' + printf '%s\n' "${changed_files[@]}" + printf 'Missing required files:\n' + printf '%s\n' "${missing_files[@]}" + printf 'Unexpected files:\n' + printf '%s\n' "${unexpected_files[@]}" exit 1 fi @@ -163,35 +221,96 @@ jobs: pr_diff_files=$(git diff --name-only \ "refs/remotes/origin/${{ inputs.base_ref }}...refs/remotes/origin/${{ steps.meta.outputs.branch }}") - if [ "$pr_diff_files" != "package.json" ]; then - echo "Existing PR $pr_url is not a package.json-only bump." + mapfile -t pr_diff_file_list <<< "$pr_diff_files" + missing_files=() + unexpected_files=() + + for required_file in package.json Cargo.toml; do + found=false + for changed_file in "${pr_diff_file_list[@]}"; do + if [ "$changed_file" = "$required_file" ]; then + found=true + break + fi + done + + if [ "$found" != "true" ]; then + missing_files+=("$required_file") + fi + done + + for changed_file in "${pr_diff_file_list[@]}"; do + case "$changed_file" in + package.json|Cargo.toml|Cargo.lock) ;; + *) unexpected_files+=("$changed_file") ;; + esac + done + + if [ "${#missing_files[@]}" -gt 0 ] || [ "${#unexpected_files[@]}" -gt 0 ]; then + echo "Existing PR $pr_url is not a package/Cargo version-only bump." printf '%s\n' "$pr_diff_files" + printf 'Missing required files:\n' + printf '%s\n' "${missing_files[@]}" + printf 'Unexpected files:\n' + printf '%s\n' "${unexpected_files[@]}" exit 1 fi package_json_file=$(mktemp) + cargo_toml_file=$(mktemp) + cargo_lock_file=$(mktemp) git show "refs/remotes/origin/${{ steps.meta.outputs.branch }}:package.json" > "$package_json_file" + git show "refs/remotes/origin/${{ steps.meta.outputs.branch }}:Cargo.toml" > "$cargo_toml_file" + git show "refs/remotes/origin/${{ steps.meta.outputs.branch }}:Cargo.lock" > "$cargo_lock_file" node --input-type=module -e " import fs from 'node:fs'; + import { + readCargoLockWorkspacePackageVersions, + readCargoWorkspacePackageVersion, + } from './scripts/bump-release-version.mjs'; const expectedVersion = process.env.EXPECTED_VERSION; const pkg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); + const cargoVersion = readCargoWorkspacePackageVersion( + fs.readFileSync(process.argv[2], 'utf8'), + ); + const cargoLockVersions = readCargoLockWorkspacePackageVersions( + fs.readFileSync(process.argv[3], 'utf8'), + ); const mismatchedOptionalDeps = Object.entries(pkg.optionalDependencies ?? {}) .filter(([, version]) => version !== expectedVersion) .map(([name, version]) => name + '@' + version); - - if (pkg.version !== expectedVersion || mismatchedOptionalDeps.length > 0) { + const mismatchedCargoLockPackages = cargoLockVersions + .filter(({ version }) => version !== expectedVersion) + .map(({ name, version }) => name + '@' + version); + + if ( + pkg.version !== expectedVersion || + cargoVersion !== expectedVersion || + cargoLockVersions.length === 0 || + mismatchedOptionalDeps.length > 0 || + mismatchedCargoLockPackages.length > 0 + ) { console.error('Existing PR head does not match expected bump version ' + expectedVersion + '.'); if (pkg.version !== expectedVersion) { console.error('package.json version: ' + pkg.version); } + if (cargoVersion !== expectedVersion) { + console.error('Cargo.toml workspace package version: ' + cargoVersion); + } + if (cargoLockVersions.length === 0) { + console.error('Cargo.lock workspace package entries were not found.'); + } if (mismatchedOptionalDeps.length > 0) { console.error('optionalDependencies mismatches: ' + mismatchedOptionalDeps.join(', ')); } + if (mismatchedCargoLockPackages.length > 0) { + console.error('Cargo.lock mismatches: ' + mismatchedCargoLockPackages.join(', ')); + } process.exit(1); } - " "$package_json_file" - rm -f "$package_json_file" + " "$package_json_file" "$cargo_toml_file" "$cargo_lock_file" + rm -f "$package_json_file" "$cargo_toml_file" "$cargo_lock_file" fi if [ -n "$pr_number" ] && [ "$has_skip_changelog" != "true" ]; then @@ -217,7 +336,7 @@ jobs: fi git switch -C "${{ steps.meta.outputs.branch }}" - git add package.json + git add package.json Cargo.toml Cargo.lock git commit -m "${{ steps.meta.outputs.title }}" git push "${lease_args[@]}" -u origin "${{ steps.meta.outputs.branch }}" @@ -252,6 +371,7 @@ jobs: - root package version을 \`${{ steps.meta.outputs.currentVersion }}\`에서 \`${{ steps.meta.outputs.version }}\`으로 올렸습니다. - root \`optionalDependencies\`의 native addon 버전도 모두 \`${{ steps.meta.outputs.version }}\`으로 맞췄습니다. + - Cargo workspace package version과 \`Cargo.lock\` workspace package metadata도 \`${{ steps.meta.outputs.version }}\`으로 맞췄습니다. ## 검증 / Verification diff --git a/Cargo.lock b/Cargo.lock index c65bc75..61c3b98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,7 +236,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kratos-cli" -version = "0.1.0" +version = "0.3.7" dependencies = [ "clap", "kratos-core", @@ -245,7 +245,7 @@ dependencies = [ [[package]] name = "kratos-core" -version = "0.1.0" +version = "0.3.7" dependencies = [ "oxc_allocator", "oxc_ast", @@ -258,7 +258,7 @@ dependencies = [ [[package]] name = "kratos-node" -version = "0.1.0" +version = "0.3.7" dependencies = [ "kratos-cli", "kratos-core", diff --git a/Cargo.toml b/Cargo.toml index 24afa31..73ef09f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.3.7" edition = "2021" license = "MIT" authors = ["JeremyDev87"] diff --git a/scripts/bump-release-version.mjs b/scripts/bump-release-version.mjs index baf0760..16829d9 100644 --- a/scripts/bump-release-version.mjs +++ b/scripts/bump-release-version.mjs @@ -26,6 +26,128 @@ export function updatePackageManifest(pkg, version) { return nextPkg; } +export function readCargoWorkspacePackageVersion(cargoToml) { + let inWorkspacePackage = false; + let sawWorkspacePackage = false; + + for (const line of cargoToml.split("\n")) { + if (/^\s*\[workspace\.package\]\s*(?:#.*)?$/.test(line)) { + inWorkspacePackage = true; + sawWorkspacePackage = true; + continue; + } + + if (inWorkspacePackage && /^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) { + inWorkspacePackage = false; + } + + if (!inWorkspacePackage) { + continue; + } + + const versionMatch = /^(\s*)version\s*=\s*"([^"]+)"(?:\s*#.*)?$/.exec(line); + if (versionMatch) { + return versionMatch[2]; + } + } + + if (!sawWorkspacePackage) { + throw new Error("Cargo.toml is missing a [workspace.package] section"); + } + + throw new Error("Cargo.toml is missing [workspace.package] version"); +} + +export function updateCargoWorkspacePackageVersion(cargoToml, version) { + const currentVersion = readCargoWorkspacePackageVersion(cargoToml); + let inWorkspacePackage = false; + let updatedVersion = false; + + const contents = cargoToml + .split("\n") + .map((line) => { + if (/^\s*\[workspace\.package\]\s*(?:#.*)?$/.test(line)) { + inWorkspacePackage = true; + return line; + } + + if (inWorkspacePackage && /^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) { + inWorkspacePackage = false; + } + + if (!inWorkspacePackage || updatedVersion) { + return line; + } + + const versionMatch = /^(\s*version\s*=\s*)"([^"]+)"(\s*(?:#.*)?)$/.exec(line); + if (!versionMatch) { + return line; + } + + updatedVersion = true; + return `${versionMatch[1]}"${version}"${versionMatch[3]}`; + }) + .join("\n"); + + return { + contents, + currentVersion, + changed: currentVersion !== version, + }; +} + +export function readCargoLockWorkspacePackageVersions(cargoLock) { + return cargoLock + .split(/(?=^\[\[package\]\]\r?\n)/m) + .flatMap((block) => { + if (!block.startsWith("[[package]]") || /^\s*source\s*=/m.test(block)) { + return []; + } + + const nameMatch = /^name\s*=\s*"([^"]+)"$/m.exec(block); + const versionMatch = /^version\s*=\s*"([^"]+)"$/m.exec(block); + + if (!nameMatch || !versionMatch) { + return []; + } + + return [ + { + name: nameMatch[1], + version: versionMatch[1], + }, + ]; + }); +} + +export function updateCargoLockWorkspacePackageVersions(cargoLock, version) { + const changedPackageNames = []; + const contents = cargoLock + .split(/(?=^\[\[package\]\]\r?\n)/m) + .map((block) => { + if (!block.startsWith("[[package]]") || /^\s*source\s*=/m.test(block)) { + return block; + } + + const nameMatch = /^name\s*=\s*"([^"]+)"$/m.exec(block); + const versionMatch = /^version\s*=\s*"([^"]+)"$/m.exec(block); + + if (!nameMatch || !versionMatch || versionMatch[1] === version) { + return block; + } + + changedPackageNames.push(nameMatch[1]); + return block.replace(/^version\s*=\s*"[^"]+"$/m, `version = "${version}"`); + }) + .join(""); + + return { + contents, + changedPackageNames, + changed: changedPackageNames.length > 0, + }; +} + export function createManualBumpBranchName(input, baseRef = "master") { const normalizedTag = normalizeReleaseTag(input); const branchBase = baseRef @@ -46,18 +168,58 @@ export function createManualBumpBranchName(input, baseRef = "master") { return `codex/manual-bump-${branchBase}-v${branchVersion}-${branchHash}`; } -export async function bumpPackageVersion(input, packageJsonPath = "package.json") { +async function readOptionalFile(filePath) { + try { + return await fs.readFile(filePath, "utf8"); + } catch (error) { + if (error && error.code === "ENOENT") { + return null; + } + + throw error; + } +} + +export async function bumpPackageVersion(input, packageJsonPath = "package.json", options = {}) { const normalizedTag = normalizeReleaseTag(input); const plan = resolveReleasePlan(normalizedTag); const manifestPath = path.resolve(packageJsonPath); - const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + const workspaceRoot = path.dirname(manifestPath); + const cargoTomlPath = path.resolve(options.cargoTomlPath ?? path.join(workspaceRoot, "Cargo.toml")); + const cargoLockPath = path.resolve(options.cargoLockPath ?? path.join(workspaceRoot, "Cargo.lock")); + const [manifestText, cargoTomlText, cargoLockText] = await Promise.all([ + fs.readFile(manifestPath, "utf8"), + fs.readFile(cargoTomlPath, "utf8"), + readOptionalFile(cargoLockPath), + ]); + const manifest = JSON.parse(manifestText); + const cargoTomlUpdate = updateCargoWorkspacePackageVersion(cargoTomlText, plan.version); + const cargoLockUpdate = + cargoLockText === null + ? { + contents: null, + changedPackageNames: [], + changed: false, + } + : updateCargoLockWorkspacePackageVersions(cargoLockText, plan.version); + assertReleaseUpgrade(manifest.version, plan.version); + assertReleaseUpgrade(cargoTomlUpdate.currentVersion, plan.version); + const nextManifest = updatePackageManifest(manifest, plan.version); await fs.writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`); + await fs.writeFile(cargoTomlPath, cargoTomlUpdate.contents); + + if (cargoLockUpdate.changed) { + await fs.writeFile(cargoLockPath, cargoLockUpdate.contents); + } return { manifestPath, + cargoTomlPath, + cargoLockPath: cargoLockText === null ? null : cargoLockPath, + cargoLockPackageNames: cargoLockUpdate.changedPackageNames, version: plan.version, tag: plan.tag, isPrerelease: plan.isPrerelease, @@ -79,6 +241,11 @@ async function main() { console.log(`version=${result.version}`); console.log(`isPrerelease=${result.isPrerelease}`); console.log(`manifestPath=${result.manifestPath}`); + console.log(`cargoTomlPath=${result.cargoTomlPath}`); + if (result.cargoLockPath) { + console.log(`cargoLockPath=${result.cargoLockPath}`); + console.log(`cargoLockPackageNames=${result.cargoLockPackageNames.join(",")}`); + } } const isDirectExecution = diff --git a/test/bump-release-version.test.js b/test/bump-release-version.test.js index a32e755..373ef4d 100644 --- a/test/bump-release-version.test.js +++ b/test/bump-release-version.test.js @@ -8,9 +8,82 @@ import { bumpPackageVersion, createManualBumpBranchName, normalizeReleaseTag, + readCargoLockWorkspacePackageVersions, + readCargoWorkspacePackageVersion, updatePackageManifest, } from "../scripts/bump-release-version.mjs"; +async function writeReleaseWorkspaceFixture( + tempRoot, + { packageVersion = "0.2.0-alpha.1", cargoVersion = packageVersion } = {}, +) { + const manifestPath = path.join(tempRoot, "package.json"); + const cargoTomlPath = path.join(tempRoot, "Cargo.toml"); + const cargoLockPath = path.join(tempRoot, "Cargo.lock"); + + await fs.writeFile( + manifestPath, + `${JSON.stringify( + { + name: "@jeremyfellaz/kratos", + version: packageVersion, + optionalDependencies: { + "@jeremyfellaz/kratos-darwin-arm64": packageVersion, + "@jeremyfellaz/kratos-win32-x64-msvc": packageVersion, + }, + }, + null, + 2, + )}\n`, + ); + + await fs.writeFile( + cargoTomlPath, + `[workspace] +members = [ + "crates/kratos-core", + "crates/kratos-cli", +] +resolver = "2" + +[workspace.package] +version = "${cargoVersion}" +edition = "2021" +license = "MIT" +`, + ); + + await fs.writeFile( + cargoLockPath, + `# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "kratos-cli" +version = "${cargoVersion}" +dependencies = [ + "kratos-core", +] + +[[package]] +name = "kratos-core" +version = "${cargoVersion}" + +[[package]] +name = "semver" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +`, + ); + + return { + manifestPath, + cargoTomlPath, + cargoLockPath, + }; +} + test("normalizeReleaseTag accepts bare versions", () => { assert.equal(normalizeReleaseTag("1.2.3"), "v1.2.3"); assert.equal(normalizeReleaseTag("v1.2.3-beta.1"), "v1.2.3-beta.1"); @@ -53,56 +126,42 @@ test("createManualBumpBranchName distinguishes base refs for the same tag", () = assert.match(release, /^codex\/manual-bump-release-1-x-v1-2-3-[0-9a-f]{8}$/); }); -test("bumpPackageVersion rewrites package.json from tag input", async () => { +test("bumpPackageVersion rewrites package.json, Cargo.toml, and Cargo.lock from tag input", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kratos-bump-version-")); - const manifestPath = path.join(tempRoot, "package.json"); - - await fs.writeFile( - manifestPath, - `${JSON.stringify( - { - name: "@jeremyfellaz/kratos", - version: "0.2.0-alpha.1", - optionalDependencies: { - "@jeremyfellaz/kratos-darwin-arm64": "0.2.0-alpha.1", - "@jeremyfellaz/kratos-win32-x64-msvc": "0.2.0-alpha.1", - }, - }, - null, - 2, - )}\n`, - ); + const { manifestPath, cargoTomlPath, cargoLockPath } = await writeReleaseWorkspaceFixture(tempRoot); const result = await bumpPackageVersion("v0.2.0", manifestPath); const updated = JSON.parse(await fs.readFile(manifestPath, "utf8")); + const cargoToml = await fs.readFile(cargoTomlPath, "utf8"); + const cargoLock = await fs.readFile(cargoLockPath, "utf8"); assert.equal(result.version, "0.2.0"); assert.equal(result.tag, "v0.2.0"); + assert.equal(result.cargoTomlPath, cargoTomlPath); + assert.equal(result.cargoLockPath, cargoLockPath); + assert.deepEqual(result.cargoLockPackageNames, ["kratos-cli", "kratos-core"]); assert.equal(updated.version, "0.2.0"); assert.deepEqual(updated.optionalDependencies, { "@jeremyfellaz/kratos-darwin-arm64": "0.2.0", "@jeremyfellaz/kratos-win32-x64-msvc": "0.2.0", }); + assert.equal(readCargoWorkspacePackageVersion(cargoToml), "0.2.0"); + assert.deepEqual(readCargoLockWorkspacePackageVersions(cargoLock), [ + { + name: "kratos-cli", + version: "0.2.0", + }, + { + name: "kratos-core", + version: "0.2.0", + }, + ]); + assert.match(cargoLock, /name = "semver"\nversion = "1\.0\.0"\nsource = /); }); test("bumpPackageVersion rejects downgrades and same-version rewrites", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kratos-bump-version-")); - const manifestPath = path.join(tempRoot, "package.json"); - - await fs.writeFile( - manifestPath, - `${JSON.stringify( - { - name: "@jeremyfellaz/kratos", - version: "0.2.0-alpha.1", - optionalDependencies: { - "@jeremyfellaz/kratos-darwin-arm64": "0.2.0-alpha.1", - }, - }, - null, - 2, - )}\n`, - ); + const { manifestPath } = await writeReleaseWorkspaceFixture(tempRoot); await assert.rejects( bumpPackageVersion("v0.1.0", manifestPath),