diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9ed526..f93c7f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -680,14 +680,33 @@ jobs: XGENY_INSTALL_DIR="$test_root/install" \ XGENY_STATE_HOME="$test_root/state" \ sh "$installer" --version "$RELEASE_TAG" >/dev/null - test "$("$test_root/install/${{ matrix.executable }}" --version)" = "xgeny ${RELEASE_TAG#v}" - "$test_root/install/${{ matrix.executable }}" licenses > "$test_root/licenses.txt" + installed="$test_root/install/${{ matrix.executable }}" + test "$("$installed" --version)" = "xgeny ${RELEASE_TAG#v}" + "$installed" licenses > "$test_root/licenses.txt" grep -Fq 'XGENy CLI Third-Party License Notices' "$test_root/licenses.txt" grep -Fq 'Copyright notices for The Rust Standard Library' "$test_root/licenses.txt" grep -Fq '===== musl C runtime notices =====' "$test_root/licenses.txt" grep -Fq '===== LLVM libunwind notices =====' "$test_root/licenses.txt" test ! -e "$test_root/state" + printf '/status\n/exit\n' \ + | HOME="$test_root/home" \ + XGENY_STATE_HOME="$test_root/state" \ + "$installed" > "$test_root/repl.txt" + grep -Fq 'XGENy Developer Preview' "$test_root/repl.txt" + grep -Fq 'status: idle' "$test_root/repl.txt" + grep -Fq 'bye' "$test_root/repl.txt" + test ! -e "$test_root/state" + + HOME="$test_root/home" \ + XGENY_INSTALL_DIR="$test_root/install" \ + XGENY_STATE_HOME="$test_root/state" \ + sh "$installer" --version "$RELEASE_TAG" >/dev/null + test -f "$installed" && test ! -L "$installed" + test "$("$installed" --version)" = "xgeny ${RELEASE_TAG#v}" + test "$(find "$test_root/install" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 + test ! -e "$test_root/state" + if [[ "$VERIFY_LATEST" == "true" && "$RELEASE_TAG" != *-* ]]; then latest_ok=false latest_installer="$test_root/xgeny-installer-latest.sh" @@ -710,6 +729,10 @@ jobs: test ! -e "$test_root/state" fi + rm -f "$installed" + test ! -e "$installed" + test "$(find "$test_root/install" -mindepth 1 -maxdepth 1 | wc -l)" -eq 0 + - name: Install exact published release on Windows if: runner.os == 'Windows' env: @@ -750,6 +773,36 @@ jobs: if (Test-Path -LiteralPath $StateRoot) { throw "published install created runtime state" } + $ReplOutput = (@("/status", "/exit") | & $Installed | Out-String) + $ReplExitCode = $LASTEXITCODE + if ( + $ReplExitCode -ne 0 -or + -not $ReplOutput.Contains("XGENy Developer Preview") -or + -not $ReplOutput.Contains("status: idle") -or + -not $ReplOutput.Contains("bye") + ) { + throw "published interactive lifecycle smoke failed" + } + if (Test-Path -LiteralPath $StateRoot) { + throw "published interactive smoke created runtime state" + } + & $Installer -Version $Tag -InstallDir $InstallRoot | Out-Null + $InstallEntries = @(Get-ChildItem -Force -LiteralPath $InstallRoot) + if ( + $InstallEntries.Count -ne 1 -or + $InstallEntries[0].Name -ne "${{ matrix.executable }}" -or + $InstallEntries[0].PSIsContainer -or + ($InstallEntries[0].Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + ) { + throw "published reinstall left an unexpected install layout" + } + $ReinstalledVersion = (& $Installed --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $ReinstalledVersion -ne "xgeny $($Tag.Substring(1))") { + throw "published reinstall produced the wrong version" + } + if (Test-Path -LiteralPath $StateRoot) { + throw "published reinstall created runtime state" + } $LicenseOutput = (& $Installed licenses | Out-String) if ( $LASTEXITCODE -ne 0 -or @@ -785,6 +838,13 @@ jobs: throw "latest install created runtime state" } } + Remove-Item -Force -LiteralPath $Installed + if (Test-Path -LiteralPath $Installed) { + throw "published binary removal failed" + } + if (@(Get-ChildItem -Force -LiteralPath $InstallRoot).Count -ne 0) { + throw "published binary removal left install artifacts" + } } finally { $env:XGENY_STATE_HOME = $PreviousState $env:XGENY_DOWNLOAD_BASE_URL = $PreviousBase @@ -970,6 +1030,35 @@ jobs: grep -Fq 'XGENy CLI Third-Party License Notices' "$test_root/licenses.txt" test ! -e "$test_root/state" + printf '/status\n/exit\n' \ + | HOME="$test_root/home" \ + XGENY_STATE_HOME="$test_root/state" \ + "$test_root/install/bin/xgeny" > "$test_root/repl.txt" + grep -Fq 'XGENy Developer Preview' "$test_root/repl.txt" + grep -Fq 'status: idle' "$test_root/repl.txt" + grep -Fq 'bye' "$test_root/repl.txt" + test ! -e "$test_root/state" + + HOME="$test_root/home" \ + npm_config_cache="$test_root/cache" \ + npm install --global --prefix "$test_root/install" \ + --include=optional --ignore-scripts --no-audit --no-fund \ + --registry=https://registry.npmjs.org/ \ + "@xgen/cli@$version" + test -f "$global_root/@xgen/cli/package.json" + test -f "$global_root/@xgen/$PLATFORM_PACKAGE/package.json" + test "$(HOME="$test_root/home" XGENY_STATE_HOME="$test_root/state" \ + "$test_root/install/bin/xgeny" --version)" = "xgeny $version" + test ! -e "$test_root/state" + + HOME="$test_root/home" \ + npm_config_cache="$test_root/cache" \ + npm uninstall --global --prefix "$test_root/install" \ + --ignore-scripts --no-audit --no-fund '@xgen/cli' + test ! -e "$global_root/@xgen/cli" + test ! -e "$global_root/@xgen/$PLATFORM_PACKAGE" + test ! -e "$test_root/install/bin/xgeny" + - name: Install and verify exact npm package on Windows if: runner.os == 'Windows' env: @@ -1024,6 +1113,53 @@ jobs: if (Test-Path -LiteralPath $StateRoot) { throw "npm install or verification created runtime state" } + $ReplOutput = (@("/status", "/exit") | & $Shim | Out-String) + $ReplExitCode = $LASTEXITCODE + if ( + $ReplExitCode -ne 0 -or + -not $ReplOutput.Contains("XGENy Developer Preview") -or + -not $ReplOutput.Contains("status: idle") -or + -not $ReplOutput.Contains("bye") + ) { + throw "npm-installed interactive lifecycle smoke failed" + } + if (Test-Path -LiteralPath $StateRoot) { + throw "npm-installed interactive smoke created runtime state" + } + + & npm.cmd install --global --prefix $InstallRoot ` + --include=optional --ignore-scripts --no-audit --no-fund ` + --registry=https://registry.npmjs.org/ ` + "@xgen/cli@$Version" + if ($LASTEXITCODE -ne 0) { throw "npm reinstall failed" } + $Reinstalled = (& $Shim --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $Reinstalled -ne "xgeny $Version") { + throw "npm reinstall produced the wrong version" + } + if ( + -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/cli/package.json")) -or + -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/$env:PLATFORM_PACKAGE/package.json")) + ) { + throw "npm reinstall is missing an exact package" + } + if (Test-Path -LiteralPath $StateRoot) { + throw "npm reinstall created runtime state" + } + + & npm.cmd uninstall --global --prefix $InstallRoot ` + --ignore-scripts --no-audit --no-fund "@xgen/cli" + if ($LASTEXITCODE -ne 0) { throw "npm uninstall failed" } + if ( + (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/cli")) -or + (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/$env:PLATFORM_PACKAGE")) + ) { + throw "npm uninstall left package artifacts" + } + foreach ($ShimName in @("xgeny", "xgeny.cmd", "xgeny.ps1")) { + if (Test-Path -LiteralPath (Join-Path $InstallRoot $ShimName)) { + throw "npm uninstall left an executable shim" + } + } } finally { $env:HOME = $PreviousHome $env:USERPROFILE = $PreviousUserProfile diff --git a/npm/scripts/smoke.mjs b/npm/scripts/smoke.mjs index eafa594..6295086 100644 --- a/npm/scripts/smoke.mjs +++ b/npm/scripts/smoke.mjs @@ -194,9 +194,6 @@ async function main() { `${launcher.name}@${version}`, ]); await run(install.command, install.args, { env: npmEnvironment }); - server.close(); - await once(server, 'close'); - server = null; assert.deepEqual(new Set(requestedTarballs), new Set([launcher.name, specification.packageName])); const root = npmInvocation(['root', '--global', '--prefix', installRoot]); @@ -251,8 +248,37 @@ async function main() { assert.match(interactiveResult.stdout, /status: idle/); assert.match(interactiveResult.stdout, /bye/); await assert.rejects(lstat(state), (error) => error.code === 'ENOENT'); + + await run(install.command, install.args, { env: npmEnvironment }); + assert.equal(JSON.parse(await readFile(installedLauncher, 'utf8')).version, version); + assert.equal(JSON.parse(await readFile(installedPlatform, 'utf8')).version, version); + await assert.rejects(lstat(state), (error) => error.code === 'ENOENT'); + + server.close(); + await once(server, 'close'); + server = null; + + const uninstall = npmInvocation([ + 'uninstall', + '--global', + '--prefix', + installRoot, + '--ignore-scripts', + '--no-audit', + '--no-fund', + launcher.name, + ]); + await run(uninstall.command, uninstall.args, { env: npmEnvironment }); + await assert.rejects(lstat(installedLauncher), (error) => error.code === 'ENOENT'); + await assert.rejects(lstat(installedPlatform), (error) => error.code === 'ENOENT'); + const installedShims = process.platform === 'win32' + ? ['xgeny', 'xgeny.cmd', 'xgeny.ps1'].map((name) => path.join(installRoot, name)) + : [path.join(installRoot, 'bin', 'xgeny')]; + for (const installedShim of installedShims) { + await assert.rejects(lstat(installedShim), (error) => error.code === 'ENOENT'); + } console.log( - `npm global install smoke: PASS (${launcher.name} -> ${specification.packageName} ${version})`, + `npm install/reinstall/remove smoke: PASS (${launcher.name} -> ${specification.packageName} ${version})`, ); } finally { if (server) { diff --git a/scripts/check-npm-distribution-workflow.sh b/scripts/check-npm-distribution-workflow.sh index 5499dcf..8ef6c5e 100755 --- a/scripts/check-npm-distribution-workflow.sh +++ b/scripts/check-npm-distribution-workflow.sh @@ -10,16 +10,21 @@ if [ "$#" -gt 1 ]; then fi workflow=${1:-"$repo_root/.github/workflows/release.yml"} -python3 - "$workflow" "$repo_root/npm/scripts/publish.mjs" <<'PY' +python3 - \ + "$workflow" \ + "$repo_root/npm/scripts/publish.mjs" \ + "$repo_root/npm/scripts/smoke.mjs" <<'PY' from pathlib import Path import re import sys workflow = Path(sys.argv[1]) publisher = Path(sys.argv[2]) +smoke = Path(sys.argv[3]) lines = workflow.read_text(encoding="utf-8").splitlines() text = "\n".join(lines) publisher_text = publisher.read_text(encoding="utf-8") +smoke_text = smoke.read_text(encoding="utf-8") def fail(message: str) -> None: @@ -86,6 +91,11 @@ for fragment in ( "--include=optional --ignore-scripts", '"@xgen/cli@$version"', '"@xgen/cli@$Version"', + "printf '/status\\n/exit\\n'", + "status: idle", + 'npm uninstall --global --prefix "$test_root/install"', + "& npm.cmd uninstall --global --prefix $InstallRoot", + "npm uninstall left package artifacts", ): if fragment not in verify_job: fail(f"published npm verification is missing: {fragment}") @@ -103,5 +113,18 @@ for fragment in ( if fragment not in publisher_text: fail(f"npm publisher is missing required safety contract: {fragment}") +for fragment in ( + "const uninstall = npmInvocation([", + "await assert.rejects(lstat(installedLauncher)", + "await assert.rejects(lstat(installedPlatform)", + "npm install/reinstall/remove smoke: PASS", +): + if fragment not in smoke_text: + fail(f"npm package smoke is missing lifecycle verification: {fragment}") + +install_invocation = "await run(install.command, install.args, { env: npmEnvironment });" +if smoke_text.count(install_invocation) != 2: + fail("npm package smoke must install the exact package twice") + print("npm release workflow contract: PASS") PY diff --git a/scripts/check-release-workflow.sh b/scripts/check-release-workflow.sh index 9b1db83..03cc81a 100644 --- a/scripts/check-release-workflow.sh +++ b/scripts/check-release-workflow.sh @@ -136,5 +136,34 @@ if actual_invocation != expected_invocation: "the classified release flags" ) +verify_starts = [index for index, line in enumerate(lines) if line == " verify-published:"] +if len(verify_starts) != 1: + raise SystemExit( + f"{workflow}: expected one verify-published job, found {len(verify_starts)}" + ) +verify_start = verify_starts[0] +verify_end = next( + ( + index + for index in range(verify_start + 1, len(lines)) + if lines[index] == " publish-npm:" + ), + len(lines), +) +verify_job = "\n".join(lines[verify_start:verify_end]) +for fragment in ( + "printf '/status\\n/exit\\n'", + "status: idle", + 'sh "$installer" --version "$RELEASE_TAG"', + 'rm -f "$installed"', + '$ReplOutput = (@("/status", "/exit")', + "published reinstall produced the wrong version", + "Remove-Item -Force -LiteralPath $Installed", +): + if fragment not in verify_job: + raise SystemExit( + f"{workflow}: published native lifecycle verification is missing: {fragment}" + ) + print("release publisher context contract: PASS") PY