diff --git a/.acecode/skills/acecode-release/SKILL.md b/.acecode/skills/acecode-release/SKILL.md index 31d7f2b5..c6c814d6 100644 --- a/.acecode/skills/acecode-release/SKILL.md +++ b/.acecode/skills/acecode-release/SKILL.md @@ -53,6 +53,18 @@ powershell -NoProfile -ExecutionPolicy Bypass ` -Repo . ``` +To validate release arguments, the selected build directory, and the dynamically +calculated parallelism without building, packaging, touching Git, or contacting +the update server, add `-DryRun` (optionally with `-BuildJobs 3`). For quick +validation, `-DryRun -NoPublish` is accepted because no publish occurs: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .acecode\skills\acecode-release\scripts\publish_acecode_release.ps1 ` + -QuickValidation -Version 0.9.13-pre.1 -Repo . ` + -DryRun -NoPublish -BuildJobs 3 +``` + With stable `0.8.6` and no existing `0.8.7-pre.N` record, this publishes `0.8.7-pre.1`. A later stable release should normally use the same numeric core, `0.8.7`. ## Seed Upgrade Compatibility (Required) diff --git a/.acecode/skills/acecode-release/scripts/publish_acecode_release.ps1 b/.acecode/skills/acecode-release/scripts/publish_acecode_release.ps1 index feac3a25..63ef9dd1 100644 --- a/.acecode/skills/acecode-release/scripts/publish_acecode_release.ps1 +++ b/.acecode/skills/acecode-release/scripts/publish_acecode_release.ps1 @@ -23,7 +23,10 @@ param( [switch]$SkipBuild, [switch]$SkipTests, [switch]$NoPublish, - [switch]$AllowDirtyBuild + [switch]$AllowDirtyBuild, + [string]$BuildDir = 'build/windows-x64-release', + [int]$BuildJobs = 0, + [switch]$DryRun ) $ErrorActionPreference = 'Stop' @@ -442,7 +445,7 @@ if (-not (Test-Path -LiteralPath (Join-Path $Repo 'CMakeLists.txt'))) { throw "Not an ACECode repo root: $Repo" } -$manifestPath = Join-Path $UpdateDir 'aceupdate.json' +$manifestPath = [System.IO.Path]::Combine($UpdateDir, 'aceupdate.json') if ($QuickValidation) { if ([string]::IsNullOrWhiteSpace($Version)) { $Version = Get-NextQuickValidationVersion -RepoRoot $Repo -ManifestPath $manifestPath @@ -453,7 +456,7 @@ if ($QuickValidation) { if ($Push) { throw 'Quick validation never pushes Git commits or tags. Remove -Push.' } - if ($NoPublish) { + if ($NoPublish -and -not $DryRun) { throw 'Quick validation must publish its Windows package. Remove -NoPublish.' } if ($Target -ne 'windows-x64') { @@ -498,6 +501,51 @@ if ($QuickValidation) { Write-Host "Repo: $Repo" Write-Host "UpdateDir: $UpdateDir" +if ($DryRun) { + $dryRunBuildRoot = $BuildDir + if (-not [System.IO.Path]::IsPathRooted($dryRunBuildRoot)) { + $dryRunBuildRoot = Join-Path $Repo $dryRunBuildRoot + } + $dryRunBuildRoot = [System.IO.Path]::GetFullPath($dryRunBuildRoot) + $dryRunJobs = if ($BuildJobs -gt 0) { $BuildJobs } else { [Environment]::ProcessorCount } + if ($dryRunJobs -lt 1) { + throw 'BuildJobs must be a positive integer.' + } + $dryRunTargetList = if ($QuickValidation) { + @('acecode', 'acecode-desktop') + } else { + @('acecode', 'acecode-desktop', 'acecode_unit_tests') + } + $dryRunBuildCommand = "cmake --build `"$dryRunBuildRoot`" --config $Configuration --target $($dryRunTargetList -join ' ') -- -j $dryRunJobs" + Write-Host 'DRY RUN: no files, builds, Git state, package, or update server will be changed.' + Write-Host "BuildDir: $dryRunBuildRoot" + Write-Host "BuildJobs: $dryRunJobs" + Write-Host "Lock: $(Join-Path $dryRunBuildRoot '.acecode-build.lock') (not acquired)" + Write-Host "Version: $Version" + if (-not $SkipBuild) { + Write-Host "Would build:" + Write-Host " $dryRunBuildCommand" + } else { + Write-Host 'Would build: skipped because -SkipBuild was supplied.' + } + if (-not $QuickValidation -and -not $SkipTests) { + Write-Host "Would test:" + Write-Host " $dryRunBuildRoot\tests\$Configuration\acecode_unit_tests.exe --gtest_filter=Upgrade*:*ConfigUpgrade*" + } + if (-not $NoPublish) { + $dryRunPackage = Join-Path $dryRunBuildRoot "package\acecode-$Version-$Target" + Write-Host "Would package:" + Write-Host " stage binaries/resources under $dryRunPackage" + Write-Host " create package archive under $UpdateDir (no archive or manifest will be written)" + if ($RemoteBaseUrl) { + Write-Host " verify package URL: $RemoteBaseUrl" + } + } else { + Write-Host 'Would package: skipped because -NoPublish was supplied.' + } + exit 0 +} + $dirtyBeforeVersionOverride = @() $versionSnapshots = $null if ($QuickValidation) { @@ -546,21 +594,37 @@ try { } } + $buildRoot = $BuildDir + if (-not [System.IO.Path]::IsPathRooted($buildRoot)) { + $buildRoot = Join-Path $Repo $buildRoot + } + $buildRoot = [System.IO.Path]::GetFullPath($buildRoot) + $jobs = if ($BuildJobs -gt 0) { $BuildJobs } else { [Environment]::ProcessorCount } + if ($jobs -lt 1) { + throw 'BuildJobs must be a positive integer.' + } + + $lockPath = Join-Path $buildRoot '.acecode-build.lock' + New-Item -ItemType Directory -Force -Path $buildRoot | Out-Null + $lockStream = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) if (-not $SkipBuild) { if ($QuickValidation) { - Invoke-Native cmake --build (Join-Path $Repo 'build') --config $Configuration --target acecode acecode-desktop + Invoke-Native cmake --build $buildRoot --config $Configuration --target acecode acecode-desktop -- -j $jobs } else { - Invoke-Native cmake --build (Join-Path $Repo 'build') --config $Configuration --target acecode acecode-desktop acecode_unit_tests + Invoke-Native cmake --build $buildRoot --config $Configuration --target acecode acecode-desktop acecode_unit_tests -- -j $jobs } + } else { + Write-Host 'Skipping build because -SkipBuild was supplied.' } if (-not $QuickValidation -and -not $SkipTests) { - $testExe = Join-Path $Repo "build\tests\$Configuration\acecode_unit_tests.exe" + $testExe = Join-Path $buildRoot "tests\$Configuration\acecode_unit_tests.exe" Invoke-Native $testExe '--gtest_filter=Upgrade*:*ConfigUpgrade*' } - $exe = Join-Path $Repo "build\$Configuration\acecode.exe" - $desktopExe = Join-Path $Repo "build\$Configuration\acecode-desktop.exe" + $exe = Join-Path $buildRoot "$Configuration\acecode.exe" + $desktopExe = Join-Path $buildRoot "$Configuration\acecode-desktop.exe" $versionOutput = (& $exe --version).Trim() if ($versionOutput -ne "acecode v$Version") { throw "Built executable reports '$versionOutput', expected 'acecode v$Version'." @@ -607,7 +671,7 @@ try { if (-not $NoPublish) { $pkgName = "acecode-$Version-$Target" - $packageRoot = Join-Path $Repo 'build\package' + $packageRoot = Join-Path $buildRoot 'package' $stage = Join-Path $packageRoot $pkgName $zipPath = Join-Path $UpdateDir "$pkgName.zip" @@ -646,6 +710,9 @@ try { $head = (& git -C $Repo rev-parse --short HEAD).Trim() } finally { + if ($null -ne $lockStream) { + $lockStream.Dispose() + } if ($QuickValidation -and $null -ne $versionSnapshots) { Restore-FileSnapshots -Snapshots $versionSnapshots } diff --git a/.acecode/skills/verify-package/SKILL.md b/.acecode/skills/verify-package/SKILL.md index 86ece467..775881b1 100644 --- a/.acecode/skills/verify-package/SKILL.md +++ b/.acecode/skills/verify-package/SKILL.md @@ -23,10 +23,10 @@ system temp dir. ## Required Inputs -None. The script detects the repo root from its own location, defaults the -build directory to `/build`, and configures it if missing (MinSizeRel, -`BUILD_TESTING=OFF`, `ACECODE_BUILD_DESKTOP=ON`, Ninja when available, vcpkg -toolchain when `VCPKG_ROOT` is set). +None. The script detects the repo root from its own location and defaults +packaging verification to the isolated `/build/windows-x64-package` +build directory. Configure and build tasks hold an exclusive lock for that +directory, so a second task fails instead of corrupting CMake/Ninja state. Prerequisite: `web/dist` must exist. If it is missing the script fails with the exact rebuild command (`cd web && pnpm install --frozen-lockfile && @@ -56,8 +56,15 @@ python3 .../verify_package.py --skip-build python3 .../verify_package.py --target tui python3 .../verify_package.py --target desktop -# Existing non-default build tree (e.g. build/windows-x64-release) -python3 .../verify_package.py --build-dir build/windows-x64-release +# Existing non-default build tree (for example, an explicitly prepared release tree) +python3 .../verify_package.py --build-dir build/windows-x64-package + +# Limit build parallelism (defaults to detected logical CPUs) +python3 .../verify_package.py --jobs 8 + +# Print the planned configure/build/install commands without changing files +python3 .../verify_package.py --dry-run +python3 .../verify_package.py --dry-run --target tui --jobs 3 # Where the staged package lands (default: /verify-package-staging) python3 .../verify_package.py --staging-dir /tmp/ace-verify @@ -65,10 +72,11 @@ python3 .../verify_package.py --staging-dir /tmp/ace-verify ## What The Script Does -1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the build dir - is configured when `--skip-build` is used. +1. Preflight: `web/dist/index.html` exists; cmake is on PATH; the isolated + package build dir is configured when `--skip-build` is used. 2. Configure + incremental build of `acecode` (and `acecode-desktop` for the - desktop target). Skipped under `--skip-build`. + desktop target), using detected logical CPUs or `--jobs`. A per-build-dir + lock prevents concurrent CMake/Ninja/package operations. 3. Staging, mirroring the CI Package step: binaries (or the macOS `ACECode.app` bundle) + READMEs, then `cmake --install --component models_dev_registry` and diff --git a/.acecode/skills/verify-package/scripts/verify_package.py b/.acecode/skills/verify-package/scripts/verify_package.py index c5bb39ce..91931aed 100644 --- a/.acecode/skills/verify-package/scripts/verify_package.py +++ b/.acecode/skills/verify-package/scripts/verify_package.py @@ -22,6 +22,10 @@ import time from pathlib import Path +REPO_ROOT_FOR_TOOLS = Path(__file__).resolve().parents[4] +if str(REPO_ROOT_FOR_TOOLS) not in sys.path: + sys.path.insert(0, str(REPO_ROOT_FOR_TOOLS)) +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") README_FILES = ("README.md", "README_CN.md") BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") @@ -222,8 +226,49 @@ def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, return ok +def print_dry_run(repo: Path, build_dir: Path, staging: Path, platform: str, + targets: list[str], jobs: int, cmake: str | None, + skip_build: bool) -> None: + """Describe package verification without creating files or running processes.""" + cmake_command = cmake or "cmake" + print("DRY RUN: no commands will be executed and no files will be changed.") + print(f"Repository: {repo}") + print(f"Build directory: {build_dir}") + print(f"Staging directory: {staging}") + print(f"Lock: {build_dir / '.acecode-build.lock'} (not acquired)") + print(f"Platform: {platform}") + print(f"Jobs: {jobs}") + if skip_build: + print("Would reuse the existing build tree (--skip-build).") + else: + configure = [cmake_command, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if platform != "windows" and shutil.which("ninja"): + configure[4:4] = ["-G", "Ninja"] + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + configure.append( + f"-DCMAKE_TOOLCHAIN_FILE={Path(vcpkg_root) / 'scripts' / 'buildsystems' / 'vcpkg.cmake'}") + print("Would run:") + print(" " + " ".join(configure)) + for target in targets: + print(" " + " ".join([ + cmake_command, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", CMAKE_TARGETS[target], "--", "-j", str(jobs), + ])) + print("Would stage package files and run structural/runtime checks.") + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + print(" " + " ".join([ + cmake_command, "--install", str(build_dir), "--config", "MinSizeRel", + "--prefix", str(staging), "--component", component, + ])) + print("Desktop/TUI runtime probes would be skipped.") + + def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, - targets: list[str], platform: str) -> bool: + targets: list[str], platform: str, jobs: int) -> bool: if not (build_dir / "CMakeCache.txt").is_file(): command = [cmake, "-S", str(repo), "-B", str(build_dir), "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", @@ -245,7 +290,7 @@ def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, cmake_target = CMAKE_TARGETS[target] if not run_tool(report, f"cmake build {cmake_target}", [cmake, "--build", str(build_dir), "--config", "MinSizeRel", - "--target", cmake_target]): + "--target", cmake_target, "--", "-j", str(jobs)]): return False return True @@ -484,13 +529,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="which artifact set to verify (default: all)") parser.add_argument("--skip-build", action="store_true", help="reuse the existing build tree; stage and verify only") + parser.add_argument("--dry-run", action="store_true", + help="print planned commands without executing or changing files") parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), default="auto", help="override platform detection (mainly for tests)") parser.add_argument("--repo", type=Path, default=None, help="ACECode repo root (default: detected from this script)") parser.add_argument("--build-dir", type=Path, default=None, - help="CMake build directory (default: /build)") + help="CMake build directory (default: build/windows-x64-package)") + parser.add_argument("--jobs", type=int, default=None, + help="parallel build jobs (default: detected logical CPUs)") parser.add_argument("--staging-dir", type=Path, default=None, help="staging output directory " "(default: /verify-package-staging)") @@ -504,26 +553,40 @@ def main(argv: list[str]) -> int: args = parse_args(argv) report = Report() repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() - build_dir = (args.build_dir or repo / "build").resolve() + build_dir = (args.build_dir or repo / "build" / "windows-x64-package").resolve() staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() platform = detect_platform(args.platform) + jobs = args.jobs if args.jobs is not None else os.cpu_count() or 1 + if jobs < 1: + print("verify-package: --jobs must be a positive integer", file=sys.stderr) + return 2 cmake = shutil.which("cmake") targets = ["tui", "desktop"] if args.target == "all" else [args.target] print(f"verify-package: repo={repo} build={build_dir} platform={platform} " f"target={args.target} skip-build={args.skip_build}") + if args.dry_run: + print_dry_run(repo, build_dir, staging, platform, targets, jobs, cmake, + args.skip_build) + return 0 + if not preflight(report, repo, cmake, args.skip_build, build_dir): print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 - if not args.skip_build: - assert cmake is not None - if not configure_and_build( - report, repo, build_dir, cmake, targets, platform): - print(f"verify-package: FAIL ({report.failed} check(s) failed)") - return 1 - - if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + try: + with build_directory_lock(build_dir): + if not args.skip_build: + assert cmake is not None + if not configure_and_build( + report, repo, build_dir, cmake, targets, platform, jobs): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + except BuildDirectoryBusy as error: + report.add("build directory safety", "fail", str(error)) print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 diff --git a/.agents/skills/verify-package/scripts/verify_package.py b/.agents/skills/verify-package/scripts/verify_package.py index c5bb39ce..91931aed 100644 --- a/.agents/skills/verify-package/scripts/verify_package.py +++ b/.agents/skills/verify-package/scripts/verify_package.py @@ -22,6 +22,10 @@ import time from pathlib import Path +REPO_ROOT_FOR_TOOLS = Path(__file__).resolve().parents[4] +if str(REPO_ROOT_FOR_TOOLS) not in sys.path: + sys.path.insert(0, str(REPO_ROOT_FOR_TOOLS)) +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") README_FILES = ("README.md", "README_CN.md") BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") @@ -222,8 +226,49 @@ def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, return ok +def print_dry_run(repo: Path, build_dir: Path, staging: Path, platform: str, + targets: list[str], jobs: int, cmake: str | None, + skip_build: bool) -> None: + """Describe package verification without creating files or running processes.""" + cmake_command = cmake or "cmake" + print("DRY RUN: no commands will be executed and no files will be changed.") + print(f"Repository: {repo}") + print(f"Build directory: {build_dir}") + print(f"Staging directory: {staging}") + print(f"Lock: {build_dir / '.acecode-build.lock'} (not acquired)") + print(f"Platform: {platform}") + print(f"Jobs: {jobs}") + if skip_build: + print("Would reuse the existing build tree (--skip-build).") + else: + configure = [cmake_command, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if platform != "windows" and shutil.which("ninja"): + configure[4:4] = ["-G", "Ninja"] + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + configure.append( + f"-DCMAKE_TOOLCHAIN_FILE={Path(vcpkg_root) / 'scripts' / 'buildsystems' / 'vcpkg.cmake'}") + print("Would run:") + print(" " + " ".join(configure)) + for target in targets: + print(" " + " ".join([ + cmake_command, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", CMAKE_TARGETS[target], "--", "-j", str(jobs), + ])) + print("Would stage package files and run structural/runtime checks.") + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + print(" " + " ".join([ + cmake_command, "--install", str(build_dir), "--config", "MinSizeRel", + "--prefix", str(staging), "--component", component, + ])) + print("Desktop/TUI runtime probes would be skipped.") + + def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, - targets: list[str], platform: str) -> bool: + targets: list[str], platform: str, jobs: int) -> bool: if not (build_dir / "CMakeCache.txt").is_file(): command = [cmake, "-S", str(repo), "-B", str(build_dir), "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", @@ -245,7 +290,7 @@ def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, cmake_target = CMAKE_TARGETS[target] if not run_tool(report, f"cmake build {cmake_target}", [cmake, "--build", str(build_dir), "--config", "MinSizeRel", - "--target", cmake_target]): + "--target", cmake_target, "--", "-j", str(jobs)]): return False return True @@ -484,13 +529,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="which artifact set to verify (default: all)") parser.add_argument("--skip-build", action="store_true", help="reuse the existing build tree; stage and verify only") + parser.add_argument("--dry-run", action="store_true", + help="print planned commands without executing or changing files") parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), default="auto", help="override platform detection (mainly for tests)") parser.add_argument("--repo", type=Path, default=None, help="ACECode repo root (default: detected from this script)") parser.add_argument("--build-dir", type=Path, default=None, - help="CMake build directory (default: /build)") + help="CMake build directory (default: build/windows-x64-package)") + parser.add_argument("--jobs", type=int, default=None, + help="parallel build jobs (default: detected logical CPUs)") parser.add_argument("--staging-dir", type=Path, default=None, help="staging output directory " "(default: /verify-package-staging)") @@ -504,26 +553,40 @@ def main(argv: list[str]) -> int: args = parse_args(argv) report = Report() repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() - build_dir = (args.build_dir or repo / "build").resolve() + build_dir = (args.build_dir or repo / "build" / "windows-x64-package").resolve() staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() platform = detect_platform(args.platform) + jobs = args.jobs if args.jobs is not None else os.cpu_count() or 1 + if jobs < 1: + print("verify-package: --jobs must be a positive integer", file=sys.stderr) + return 2 cmake = shutil.which("cmake") targets = ["tui", "desktop"] if args.target == "all" else [args.target] print(f"verify-package: repo={repo} build={build_dir} platform={platform} " f"target={args.target} skip-build={args.skip_build}") + if args.dry_run: + print_dry_run(repo, build_dir, staging, platform, targets, jobs, cmake, + args.skip_build) + return 0 + if not preflight(report, repo, cmake, args.skip_build, build_dir): print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 - if not args.skip_build: - assert cmake is not None - if not configure_and_build( - report, repo, build_dir, cmake, targets, platform): - print(f"verify-package: FAIL ({report.failed} check(s) failed)") - return 1 - - if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + try: + with build_directory_lock(build_dir): + if not args.skip_build: + assert cmake is not None + if not configure_and_build( + report, repo, build_dir, cmake, targets, platform, jobs): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + except BuildDirectoryBusy as error: + report.add("build directory safety", "fail", str(error)) print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 diff --git a/.claude/skills/verify-package/scripts/verify_package.py b/.claude/skills/verify-package/scripts/verify_package.py index c5bb39ce..91931aed 100644 --- a/.claude/skills/verify-package/scripts/verify_package.py +++ b/.claude/skills/verify-package/scripts/verify_package.py @@ -22,6 +22,10 @@ import time from pathlib import Path +REPO_ROOT_FOR_TOOLS = Path(__file__).resolve().parents[4] +if str(REPO_ROOT_FOR_TOOLS) not in sys.path: + sys.path.insert(0, str(REPO_ROOT_FOR_TOOLS)) +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") README_FILES = ("README.md", "README_CN.md") BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") @@ -222,8 +226,49 @@ def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, return ok +def print_dry_run(repo: Path, build_dir: Path, staging: Path, platform: str, + targets: list[str], jobs: int, cmake: str | None, + skip_build: bool) -> None: + """Describe package verification without creating files or running processes.""" + cmake_command = cmake or "cmake" + print("DRY RUN: no commands will be executed and no files will be changed.") + print(f"Repository: {repo}") + print(f"Build directory: {build_dir}") + print(f"Staging directory: {staging}") + print(f"Lock: {build_dir / '.acecode-build.lock'} (not acquired)") + print(f"Platform: {platform}") + print(f"Jobs: {jobs}") + if skip_build: + print("Would reuse the existing build tree (--skip-build).") + else: + configure = [cmake_command, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if platform != "windows" and shutil.which("ninja"): + configure[4:4] = ["-G", "Ninja"] + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + configure.append( + f"-DCMAKE_TOOLCHAIN_FILE={Path(vcpkg_root) / 'scripts' / 'buildsystems' / 'vcpkg.cmake'}") + print("Would run:") + print(" " + " ".join(configure)) + for target in targets: + print(" " + " ".join([ + cmake_command, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", CMAKE_TARGETS[target], "--", "-j", str(jobs), + ])) + print("Would stage package files and run structural/runtime checks.") + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + print(" " + " ".join([ + cmake_command, "--install", str(build_dir), "--config", "MinSizeRel", + "--prefix", str(staging), "--component", component, + ])) + print("Desktop/TUI runtime probes would be skipped.") + + def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, - targets: list[str], platform: str) -> bool: + targets: list[str], platform: str, jobs: int) -> bool: if not (build_dir / "CMakeCache.txt").is_file(): command = [cmake, "-S", str(repo), "-B", str(build_dir), "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", @@ -245,7 +290,7 @@ def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, cmake_target = CMAKE_TARGETS[target] if not run_tool(report, f"cmake build {cmake_target}", [cmake, "--build", str(build_dir), "--config", "MinSizeRel", - "--target", cmake_target]): + "--target", cmake_target, "--", "-j", str(jobs)]): return False return True @@ -484,13 +529,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="which artifact set to verify (default: all)") parser.add_argument("--skip-build", action="store_true", help="reuse the existing build tree; stage and verify only") + parser.add_argument("--dry-run", action="store_true", + help="print planned commands without executing or changing files") parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), default="auto", help="override platform detection (mainly for tests)") parser.add_argument("--repo", type=Path, default=None, help="ACECode repo root (default: detected from this script)") parser.add_argument("--build-dir", type=Path, default=None, - help="CMake build directory (default: /build)") + help="CMake build directory (default: build/windows-x64-package)") + parser.add_argument("--jobs", type=int, default=None, + help="parallel build jobs (default: detected logical CPUs)") parser.add_argument("--staging-dir", type=Path, default=None, help="staging output directory " "(default: /verify-package-staging)") @@ -504,26 +553,40 @@ def main(argv: list[str]) -> int: args = parse_args(argv) report = Report() repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() - build_dir = (args.build_dir or repo / "build").resolve() + build_dir = (args.build_dir or repo / "build" / "windows-x64-package").resolve() staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() platform = detect_platform(args.platform) + jobs = args.jobs if args.jobs is not None else os.cpu_count() or 1 + if jobs < 1: + print("verify-package: --jobs must be a positive integer", file=sys.stderr) + return 2 cmake = shutil.which("cmake") targets = ["tui", "desktop"] if args.target == "all" else [args.target] print(f"verify-package: repo={repo} build={build_dir} platform={platform} " f"target={args.target} skip-build={args.skip_build}") + if args.dry_run: + print_dry_run(repo, build_dir, staging, platform, targets, jobs, cmake, + args.skip_build) + return 0 + if not preflight(report, repo, cmake, args.skip_build, build_dir): print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 - if not args.skip_build: - assert cmake is not None - if not configure_and_build( - report, repo, build_dir, cmake, targets, platform): - print(f"verify-package: FAIL ({report.failed} check(s) failed)") - return 1 - - if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + try: + with build_directory_lock(build_dir): + if not args.skip_build: + assert cmake is not None + if not configure_and_build( + report, repo, build_dir, cmake, targets, platform, jobs): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + except BuildDirectoryBusy as error: + report.add("build directory safety", "fail", str(error)) print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 diff --git a/.codex/skills/verify-package/scripts/verify_package.py b/.codex/skills/verify-package/scripts/verify_package.py index c5bb39ce..91931aed 100644 --- a/.codex/skills/verify-package/scripts/verify_package.py +++ b/.codex/skills/verify-package/scripts/verify_package.py @@ -22,6 +22,10 @@ import time from pathlib import Path +REPO_ROOT_FOR_TOOLS = Path(__file__).resolve().parents[4] +if str(REPO_ROOT_FOR_TOOLS) not in sys.path: + sys.path.insert(0, str(REPO_ROOT_FOR_TOOLS)) +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock MODELS_DEV_FILES = ("api.json", "MANIFEST.json", "LICENSE") README_FILES = ("README.md", "README_CN.md") BUILD_CONFIGS = ("MinSizeRel", "Release", "RelWithDebInfo", "Debug") @@ -222,8 +226,49 @@ def preflight(report: Report, repo: Path, cmake: str | None, skip_build: bool, return ok +def print_dry_run(repo: Path, build_dir: Path, staging: Path, platform: str, + targets: list[str], jobs: int, cmake: str | None, + skip_build: bool) -> None: + """Describe package verification without creating files or running processes.""" + cmake_command = cmake or "cmake" + print("DRY RUN: no commands will be executed and no files will be changed.") + print(f"Repository: {repo}") + print(f"Build directory: {build_dir}") + print(f"Staging directory: {staging}") + print(f"Lock: {build_dir / '.acecode-build.lock'} (not acquired)") + print(f"Platform: {platform}") + print(f"Jobs: {jobs}") + if skip_build: + print("Would reuse the existing build tree (--skip-build).") + else: + configure = [cmake_command, "-S", str(repo), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", + "-DACECODE_BUILD_DESKTOP=ON"] + if platform != "windows" and shutil.which("ninja"): + configure[4:4] = ["-G", "Ninja"] + vcpkg_root = os.environ.get("VCPKG_ROOT") + if vcpkg_root: + configure.append( + f"-DCMAKE_TOOLCHAIN_FILE={Path(vcpkg_root) / 'scripts' / 'buildsystems' / 'vcpkg.cmake'}") + print("Would run:") + print(" " + " ".join(configure)) + for target in targets: + print(" " + " ".join([ + cmake_command, "--build", str(build_dir), "--config", "MinSizeRel", + "--target", CMAKE_TARGETS[target], "--", "-j", str(jobs), + ])) + print("Would stage package files and run structural/runtime checks.") + if "tui" in targets or platform != "darwin": + for component in ("models_dev_registry", "default_seed_bundle"): + print(" " + " ".join([ + cmake_command, "--install", str(build_dir), "--config", "MinSizeRel", + "--prefix", str(staging), "--component", component, + ])) + print("Desktop/TUI runtime probes would be skipped.") + + def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, - targets: list[str], platform: str) -> bool: + targets: list[str], platform: str, jobs: int) -> bool: if not (build_dir / "CMakeCache.txt").is_file(): command = [cmake, "-S", str(repo), "-B", str(build_dir), "-DCMAKE_BUILD_TYPE=MinSizeRel", "-DBUILD_TESTING=OFF", @@ -245,7 +290,7 @@ def configure_and_build(report: Report, repo: Path, build_dir: Path, cmake: str, cmake_target = CMAKE_TARGETS[target] if not run_tool(report, f"cmake build {cmake_target}", [cmake, "--build", str(build_dir), "--config", "MinSizeRel", - "--target", cmake_target]): + "--target", cmake_target, "--", "-j", str(jobs)]): return False return True @@ -484,13 +529,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help="which artifact set to verify (default: all)") parser.add_argument("--skip-build", action="store_true", help="reuse the existing build tree; stage and verify only") + parser.add_argument("--dry-run", action="store_true", + help="print planned commands without executing or changing files") parser.add_argument("--platform", choices=("auto", "darwin", "windows", "linux"), default="auto", help="override platform detection (mainly for tests)") parser.add_argument("--repo", type=Path, default=None, help="ACECode repo root (default: detected from this script)") parser.add_argument("--build-dir", type=Path, default=None, - help="CMake build directory (default: /build)") + help="CMake build directory (default: build/windows-x64-package)") + parser.add_argument("--jobs", type=int, default=None, + help="parallel build jobs (default: detected logical CPUs)") parser.add_argument("--staging-dir", type=Path, default=None, help="staging output directory " "(default: /verify-package-staging)") @@ -504,26 +553,40 @@ def main(argv: list[str]) -> int: args = parse_args(argv) report = Report() repo = (args.repo or find_repo_root(Path(__file__).resolve())).resolve() - build_dir = (args.build_dir or repo / "build").resolve() + build_dir = (args.build_dir or repo / "build" / "windows-x64-package").resolve() staging = (args.staging_dir or build_dir / STAGING_DIRNAME).resolve() platform = detect_platform(args.platform) + jobs = args.jobs if args.jobs is not None else os.cpu_count() or 1 + if jobs < 1: + print("verify-package: --jobs must be a positive integer", file=sys.stderr) + return 2 cmake = shutil.which("cmake") targets = ["tui", "desktop"] if args.target == "all" else [args.target] print(f"verify-package: repo={repo} build={build_dir} platform={platform} " f"target={args.target} skip-build={args.skip_build}") + if args.dry_run: + print_dry_run(repo, build_dir, staging, platform, targets, jobs, cmake, + args.skip_build) + return 0 + if not preflight(report, repo, cmake, args.skip_build, build_dir): print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 - if not args.skip_build: - assert cmake is not None - if not configure_and_build( - report, repo, build_dir, cmake, targets, platform): - print(f"verify-package: FAIL ({report.failed} check(s) failed)") - return 1 - - if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + try: + with build_directory_lock(build_dir): + if not args.skip_build: + assert cmake is not None + if not configure_and_build( + report, repo, build_dir, cmake, targets, platform, jobs): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + if not stage(report, repo, build_dir, staging, platform, targets, cmake or "cmake"): + print(f"verify-package: FAIL ({report.failed} check(s) failed)") + return 1 + except BuildDirectoryBusy as error: + report.add("build directory safety", "fail", str(error)) print(f"verify-package: FAIL ({report.failed} check(s) failed)") return 1 diff --git a/run_build.py b/run_build.py new file mode 100644 index 00000000..91637279 --- /dev/null +++ b/run_build.py @@ -0,0 +1,135 @@ +import argparse +import os +import subprocess +import sys +from pathlib import Path + +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock + +REPO_ROOT = Path(__file__).resolve().parent + +# Build launcher: runs `cmake --build build/windows-x64-dev` with the full +# MSVC + Windows SDK environment injected directly (no vcvars, because reg.exe +# is blacklisted). Mirrors run_cmake_configure.py. +# +# Ninja's default parallelism is selected from the host's logical processor +# count. Use --jobs or ACECODE_BUILD_JOBS to override it for memory-constrained +# machines or when other CPU-heavy work is running. + +cmake = r"C:\dev\tools\cmake-3.31.6-windows-x86_64\bin\cmake.exe" +ninja = r"C:\dev\tools\ninja" +VCPKG_ROOT = r"C:\dev\tools\vcpkg" + +SDK = r"C:\Program Files (x86)\Windows Kits\10" +SDK_VER = r"10.0.26100.0" +SDK_BIN = SDK + r"\bin\\" + SDK_VER + r"\x64" +SDK_INC = SDK + r"\Include\\" + SDK_VER +SDK_LIB = SDK + r"\Lib\\" + SDK_VER +MSVC = r"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207" +MSVC_BIN = MSVC + r"\bin\Hostx64\x64" +MSVC_INC = MSVC + r"\include" +MSVC_LIB = MSVC + r"\lib\x64" + +env = dict(os.environ) + +def prepend(name, dirs): + cur = env.get(name, "") + parts = [d for d in dirs if d and d not in cur] + if parts: + env[name] = ";".join(parts) + (";" + cur if cur else "") + +prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja]) +prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"]) +prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"]) + +env["VCPKG_ROOT"] = VCPKG_ROOT +env["VCPKG_DEFAULT_TRIPLET"] = "x64-windows-static" + + +def positive_int(value): + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError("must be a positive integer") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return parsed + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Build an ACECode target with hardware-aware Ninja parallelism." + ) + parser.add_argument("target", nargs="?", help="optional CMake target") + parser.add_argument( + "--build-dir", type=Path, default=Path("build/windows-x64-dev"), + help="CMake build directory (default: build/windows-x64-dev)", + ) + parser.add_argument( + "-j", "--jobs", type=positive_int, + help="maximum parallel build jobs (overrides ACECODE_BUILD_JOBS and auto-detection)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the build command without executing it or acquiring the build lock", + ) + return parser.parse_args() + + +def detect_jobs(): + """Return the host's detected logical processor count.""" + return os.cpu_count() or 1 + + +args = parse_args() +environment_jobs = os.environ.get("ACECODE_BUILD_JOBS") +if args.jobs is not None: + jobs = args.jobs + jobs_source = "--jobs" +elif environment_jobs is not None: + try: + jobs = positive_int(environment_jobs) + except argparse.ArgumentTypeError as exc: + print(f"Invalid ACECODE_BUILD_JOBS={environment_jobs!r}: {exc}", file=sys.stderr) + sys.exit(2) + jobs_source = "ACECODE_BUILD_JOBS" +else: + jobs = detect_jobs() + jobs_source = "auto-detected" + +target = args.target +build_dir = args.build_dir +if not build_dir.is_absolute(): + build_dir = Path(__file__).resolve().parent / build_dir +build_dir = build_dir.resolve() + +cmd = [cmake, "--build", str(build_dir)] +if target: + cmd += ["--target", target] +# Ninja controls process-level parallelism; MSVC /MP remains enabled by CMake. +cmd += ["--", "-j", str(jobs)] + +print(f"Using {jobs} parallel build jobs ({jobs_source}; logical CPUs: {os.cpu_count() or 1})") +print("Build directory:", build_dir) +print("Lock:", build_dir / ".acecode-build.lock") +if args.dry_run: + print("DRY RUN: no commands will be executed and no files will be changed.") + print("Would run:", " ".join('"' + c + '"' if " " in c else c for c in cmd)) + sys.exit(0) +print("Running:", " ".join('"' + c + '"' if " " in c else c for c in cmd)) +try: + with build_directory_lock(build_dir): + p = subprocess.run(cmd, capture_output=True, encoding="gbk", errors="replace", + shell=False, env=env, cwd=str(REPO_ROOT)) +except BuildDirectoryBusy as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(3) +print("=== OUTPUT (first 3000) ===") +print((p.stdout or "")[:3000]) +print((p.stderr or "")[:3000]) +print("=== OUTPUT (last 8000) ===") +print((p.stdout or "")[-8000:]) +print((p.stderr or "")[-3000:]) +print("RETURNCODE:", p.returncode) +sys.exit(p.returncode) diff --git a/run_cmake_configure.py b/run_cmake_configure.py new file mode 100644 index 00000000..a27c9ea4 --- /dev/null +++ b/run_cmake_configure.py @@ -0,0 +1,102 @@ +import argparse +import subprocess +import sys +import os +from pathlib import Path + +from scripts.build_lock import BuildDirectoryBusy, build_directory_lock + +# Python launcher that runs `cmake -S . -B build/windows-x64-dev` with the +# full MSVC + Windows SDK environment injected directly (no reliance on +# vcvars64.bat, because reg.exe is blacklisted by the security policy and +# causes vcvars to hang/fail). +# +# The vcpkg toolchain file must be passed so find_package() locates the +# manifest-installed packages under vcpkg_installed//. + +cmake = r"C:\dev\tools\cmake-3.31.6-windows-x86_64\bin\cmake.exe" +ninja = r"C:\dev\tools\ninja" +VCPKG_ROOT = r"C:\dev\tools\vcpkg" + +# --- Inject MSVC + Windows SDK environment (mirrors run_vcpkg_install.py) --- +SDK = r"C:\Program Files (x86)\Windows Kits\10" +SDK_VER = r"10.0.26100.0" +SDK_BIN = SDK + r"\bin\\" + SDK_VER + r"\x64" +SDK_INC = SDK + r"\Include\\" + SDK_VER +SDK_LIB = SDK + r"\Lib\\" + SDK_VER +MSVC = r"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207" +MSVC_BIN = MSVC + r"\bin\Hostx64\x64" +MSVC_INC = MSVC + r"\include" +MSVC_LIB = MSVC + r"\lib\x64" + +env = dict(os.environ) + +def prepend(name, dirs): + cur = env.get(name, "") + parts = [d for d in dirs if d and d not in cur] + if parts: + env[name] = ";".join(parts) + (";" + cur if cur else "") + +prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja]) +prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"]) +prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"]) + +# vcpkg config for manifest-mode find_package +env["VCPKG_ROOT"] = VCPKG_ROOT +env["VCPKG_DEFAULT_TRIPLET"] = "x64-windows-static" + +def parse_args(): + parser = argparse.ArgumentParser(description="Configure the ACECode development build tree.") + parser.add_argument( + "--build-dir", type=Path, default=Path("build/windows-x64-dev"), + help="CMake build directory (default: build/windows-x64-dev)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the configure command without executing it or acquiring the build lock", + ) + return parser.parse_args() + + +args = parse_args() +repo_root = Path(__file__).resolve().parent +build_dir = args.build_dir if args.build_dir.is_absolute() else repo_root / args.build_dir +build_dir = build_dir.resolve() + +cmd = [ + cmake, "-S", str(repo_root), "-B", str(build_dir), + "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_TOOLCHAIN_FILE=" + VCPKG_ROOT + r"\scripts\buildsystems\vcpkg.cmake", + "-DVCPKG_ROOT=" + VCPKG_ROOT, + "-DVCPKG_TARGET_TRIPLET=x64-windows-static", + "-DVCPKG_MANIFEST_MODE=ON", + "-DVCPKG_MANIFEST_FEATURES=tests", + "-DVCPKG_OVERLAY_PORTS=" + str(repo_root / "ports"), + "-DBUILD_TESTING=ON", + "-DACECODE_BUILD_DESKTOP=ON", +] + +print("Build directory:", build_dir) +print("Lock:", build_dir / ".acecode-build.lock") +if args.dry_run: + print("DRY RUN: no commands will be executed and no files will be changed.") + print("Would run:", " ".join('"' + c + '"' if " " in c else c for c in cmd)) + sys.exit(0) +print("Running:", " ".join('"' + c + '"' if " " in c else c for c in cmd)) +try: + with build_directory_lock(build_dir): + p = subprocess.run(cmd, capture_output=True, encoding="gbk", errors="replace", + shell=False, env=env, cwd=str(repo_root)) +except BuildDirectoryBusy as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(3) +print("=== OUTPUT (first 3000) ===") +print((p.stdout or "")[:3000]) +print((p.stderr or "")[:3000]) +print("=== OUTPUT (last 8000) ===") +print((p.stdout or "")[-8000:]) +print((p.stderr or "")[-3000:]) +print("RETURNCODE:", p.returncode) +sys.exit(p.returncode) diff --git a/scripts/build_lock.py b/scripts/build_lock.py new file mode 100644 index 00000000..a1fe9548 --- /dev/null +++ b/scripts/build_lock.py @@ -0,0 +1,59 @@ +"""Cross-platform exclusive locks for build directories.""" + +from __future__ import annotations + +import contextlib +import os +from pathlib import Path +from typing import Iterator, TextIO + + +class BuildDirectoryBusy(RuntimeError): + """Raised when another process is using the same build directory.""" + + +def _lock_file(handle: TextIO) -> None: + handle.seek(0) + handle.write("ACECode build directory lock\n") + handle.flush() + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock_file(handle: TextIO) -> None: + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@contextlib.contextmanager +def build_directory_lock(build_dir: Path) -> Iterator[None]: + """Hold an OS-released exclusive lock for the lifetime of a build task.""" + build_dir = build_dir.resolve() + build_dir.mkdir(parents=True, exist_ok=True) + lock_path = build_dir / ".acecode-build.lock" + with lock_path.open("a+", encoding="ascii") as handle: + try: + _lock_file(handle) + except (OSError, PermissionError) as error: + raise BuildDirectoryBusy( + f"build directory is busy: {build_dir}; " + "do not run two CMake/Ninja/package tasks against it at once" + ) from error + try: + yield + finally: + _unlock_file(handle) diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index 6347fd2b..a233f185 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -26,9 +26,6 @@ import sys from pathlib import Path - -# ─── 颜色输出(终端支持时) ─────────────────────────────────────────────── - def _supports_color() -> bool: if os.environ.get("NO_COLOR"): return False @@ -320,7 +317,7 @@ def main() -> None: parser.add_argument("--rebuild", action="store_true", help="强制重新构建 web 前端") parser.add_argument("--no-build", action="store_true", help="跳过 web 构建,直接启动 desktop") parser.add_argument("--list", action="store_true", help="列出可用的 desktop 构建产物并退出") - parser.add_argument("--build-dir", type=str, default=None, help="指定 desktop 构建目录(相对于项目根或绝对路径)") + parser.add_argument("--build-dir", type=str, default="build/windows-x64-dev", help="指定 desktop 构建目录(相对于项目根或绝对路径)") parser.add_argument("--root", type=str, default=None, help="指定项目根目录(自动检测失败时使用)") args = parser.parse_args() @@ -338,7 +335,10 @@ def main() -> None: info(f"项目根目录: {project_root}") web_dir = project_root / "web" - build_dir = project_root / "build" + build_dir = Path(args.build_dir) + if not build_dir.is_absolute(): + build_dir = project_root / build_dir + build_dir = build_dir.resolve() dev_web_dir = web_dir / "dist" # 2. --list 模式 diff --git a/tests/scripts/verify_package_unit_test.py b/tests/scripts/verify_package_unit_test.py index a5eae3d1..18001a6c 100644 --- a/tests/scripts/verify_package_unit_test.py +++ b/tests/scripts/verify_package_unit_test.py @@ -73,13 +73,17 @@ def capture(_report, _name, command, **_kwargs): mock.patch.object(verify_package.shutil, "which", return_value="ninja"): result = verify_package.configure_and_build( verify_package.Report(), repo, build, "cmake", - ["tui", "desktop"], "windows" + ["tui", "desktop"], "windows", jobs=4 ) self.assertTrue(result) self.assertNotIn("-G", commands[0]) - self.assertEqual(commands[1][-1], "acecode") - self.assertEqual(commands[2][-1], "acecode-desktop") + self.assertEqual( + commands[1][commands[1].index("--target") + 1], "acecode") + self.assertEqual( + commands[2][commands[2].index("--target") + 1], "acecode-desktop") + for command in commands[1:]: + self.assertEqual(command[-2:], ["-j", "4"]) def test_non_windows_prefers_ninja(self) -> None: with tempfile.TemporaryDirectory() as root_text: @@ -96,10 +100,12 @@ def capture(_report, _name, command, **_kwargs): with mock.patch.object(verify_package, "run_tool", side_effect=capture), \ mock.patch.object(verify_package.shutil, "which", return_value="ninja"): verify_package.configure_and_build( - verify_package.Report(), repo, build, "cmake", ["tui"], "linux" + verify_package.Report(), repo, build, "cmake", ["tui"], "linux", + jobs=4 ) self.assertEqual(commands[0][4:6], ["-G", "Ninja"]) + self.assertEqual(commands[1][-2:], ["-j", "4"]) def test_staging_path_guard_rejects_protected_paths(self) -> None: with tempfile.TemporaryDirectory() as root_text: