From 482805b9f1f147a6901c48327d40a82cc92fb964 Mon Sep 17 00:00:00 2001 From: Joanna Wang Date: Mon, 15 Jun 2026 20:00:47 +0000 Subject: [PATCH] test: add package verification step to CI Verify that the built packages can be installed and run (exiting with 0 when called with --help) in a clean environment without devDependencies. This catches cases where devDependencies are accidentally used as production dependencies. Added --help support to nextjs and angular adapter build/create binaries to allow them to exit early with success instead of trying to run a full build/create during verification. BUG=b/466103915 TAG=agy CONV=25a32dbb-c6bf-4394-be10-d693f5f74670 --- .github/workflows/test.yml | 20 +- .../adapter-angular/src/bin/build.ts | 41 ++-- .../adapter-nextjs/src/bin/build.ts | 109 ++++----- packages/@apphosting/build/package.json | 5 +- scripts/verify-packages.js | 212 ++++++++++++++++++ 5 files changed, 312 insertions(+), 75 deletions(-) create mode 100644 scripts/verify-packages.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ddcf0e8bd..c30081f0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,11 +120,29 @@ jobs: - name: Test run: npm run lint:quiet + verify_packages: + runs-on: ubuntu-latest + needs: build + name: Verify Packages Installation + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + check-latest: false + - name: Download Artifacts + uses: actions/download-artifact@v4 + - name: Rsync Artifacts + run: rsync -a artifact/ packages + - name: Run verification script + run: node scripts/verify-packages.js + # Break the branch protection test into a separate step, so we can manage the matrix more easily test_and_contribute: runs-on: ubuntu-latest name: Branch protection - needs: ["test", "lint"] + needs: ["test", "lint", "verify_packages"] steps: - run: true diff --git a/packages/@apphosting/adapter-angular/src/bin/build.ts b/packages/@apphosting/adapter-angular/src/bin/build.ts index 0776a4646..6d44a032f 100644 --- a/packages/@apphosting/adapter-angular/src/bin/build.ts +++ b/packages/@apphosting/adapter-angular/src/bin/build.ts @@ -1,5 +1,6 @@ #! /usr/bin/env node import { + isMain, generateBuildOutput, checkBuildConditions, validateOutputDirectory, @@ -8,27 +9,29 @@ import { } from "../utils.js"; import { getBuildOptions, runBuild } from "@apphosting/common"; -const opts = getBuildOptions(); +if (isMain(import.meta)) { + const opts = getBuildOptions(); -// Check build conditions, which vary depending on your project structure (standalone or monorepo) -await checkBuildConditions(opts); + // Check build conditions, which vary depending on your project structure (standalone or monorepo) + await checkBuildConditions(opts); -// enable JSON build logs for application builder -process.env.NG_BUILD_LOGS_JSON = "1"; -const { stdout: output } = await runBuild(); -if (!output) { - throw new Error("No output from Angular build command, expecting a build manifest file."); -} + // enable JSON build logs for application builder + process.env.NG_BUILD_LOGS_JSON = "1"; + const { stdout: output } = await runBuild(); + if (!output) { + throw new Error("No output from Angular build command, expecting a build manifest file."); + } -const angularVersion = process.env.FRAMEWORK_VERSION || "unspecified"; -// Frameworks like nitro, analog, nuxt generate the output bundle during their own build process -// when `npm run build` is called which we don't want to overwrite immediately after. -// We only want to overwrite if the existing output is from a previous framework adapter -// build on a plain angular app. -if (!metaFrameworkOutputBundleExists()) { - const outputBundleOptions = parseOutputBundleOptions(output); - const root = process.cwd(); - await generateBuildOutput(root, outputBundleOptions, angularVersion); + const angularVersion = process.env.FRAMEWORK_VERSION || "unspecified"; + // Frameworks like nitro, analog, nuxt generate the output bundle during their own build process + // when `npm run build` is called which we don't want to overwrite immediately after. + // We only want to overwrite if the existing output is from a previous framework adapter + // build on a plain angular app. + if (!metaFrameworkOutputBundleExists()) { + const outputBundleOptions = parseOutputBundleOptions(output); + const root = process.cwd(); + await generateBuildOutput(root, outputBundleOptions, angularVersion); - await validateOutputDirectory(outputBundleOptions); + await validateOutputDirectory(outputBundleOptions); + } } diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 3d46dbd03..5a96ba0a4 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -1,5 +1,6 @@ #! /usr/bin/env node import { + isMain, loadConfig, populateOutputBundleOptions, generateBuildOutput, @@ -17,64 +18,66 @@ import { validateNextConfigOverride, } from "../overrides.js"; -const root = process.cwd(); -const opts = getBuildOptions(); +if (isMain(import.meta)) { + const root = process.cwd(); + const opts = getBuildOptions(); -// Set standalone mode -process.env.NEXT_PRIVATE_STANDALONE = "true"; -// Opt-out sending telemetry to Vercel -process.env.NEXT_TELEMETRY_DISABLED = "1"; + // Set standalone mode + process.env.NEXT_PRIVATE_STANDALONE = "true"; + // Opt-out sending telemetry to Vercel + process.env.NEXT_TELEMETRY_DISABLED = "1"; -checkNextJSVersion(process.env.FRAMEWORK_VERSION); -const nextConfig = await loadConfig(root, opts.projectDirectory); + checkNextJSVersion(process.env.FRAMEWORK_VERSION); + const nextConfig = await loadConfig(root, opts.projectDirectory); -/** - * Override user's Next Config to optimize the app for Firebase App Hosting - * and validate that the override resulted in a valid config that Next.js can - * load. - * - * We restore the user's Next Config at the end of the build, after the config file has been - * copied over to the output directory, so that the user's original code is not modified. - * - * If the app does not have a next.config.[js|mjs|ts] file in the first place, - * then can skip config override. - * - * Note: loadConfig always returns a fileName (default: next.config.js) even if - * one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115 - */ -const nextConfigPath = join(root, nextConfig.configFileName); -if (await exists(nextConfigPath)) { - await overrideNextConfig(root, nextConfig.configFileName); - await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); -} + /** + * Override user's Next Config to optimize the app for Firebase App Hosting + * and validate that the override resulted in a valid config that Next.js can + * load. + * + * We restore the user's Next Config at the end of the build, after the config file has been + * copied over to the output directory, so that the user's original code is not modified. + * + * If the app does not have a next.config.[js|mjs|ts] file in the first place, + * then can skip config override. + * + * Note: loadConfig always returns a fileName (default: next.config.js) even if + * one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115 + */ + const nextConfigPath = join(root, nextConfig.configFileName); + if (await exists(nextConfigPath)) { + await overrideNextConfig(root, nextConfig.configFileName); + await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); + } -try { - await runBuild(); + try { + await runBuild(); - const adapterMetadata = getAdapterMetadata(); - const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir); - const outputBundleOptions = populateOutputBundleOptions( - root, - opts.projectDirectory, - nextBuildDirectory, - ); + const adapterMetadata = getAdapterMetadata(); + const nextBuildDirectory = join(opts.projectDirectory, nextConfig.distDir); + const outputBundleOptions = populateOutputBundleOptions( + root, + opts.projectDirectory, + nextBuildDirectory, + ); - await addRouteOverrides( - outputBundleOptions.outputDirectoryAppPath, - nextConfig.distDir, - adapterMetadata, - ); + await addRouteOverrides( + outputBundleOptions.outputDirectoryAppPath, + nextConfig.distDir, + adapterMetadata, + ); - const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified"; - await generateBuildOutput( - root, - opts.projectDirectory, - outputBundleOptions, - nextBuildDirectory, - nextjsVersion, - adapterMetadata, - ); - await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); -} finally { - await restoreNextConfig(root, nextConfig.configFileName); + const nextjsVersion = process.env.FRAMEWORK_VERSION || "unspecified"; + await generateBuildOutput( + root, + opts.projectDirectory, + outputBundleOptions, + nextBuildDirectory, + nextjsVersion, + adapterMetadata, + ); + await validateOutputDirectory(outputBundleOptions, nextBuildDirectory); + } finally { + await restoreNextConfig(root, nextConfig.configFileName); + } } diff --git a/packages/@apphosting/build/package.json b/packages/@apphosting/build/package.json index 65867e3de..a4c5fc8c7 100644 --- a/packages/@apphosting/build/package.json +++ b/packages/@apphosting/build/package.json @@ -38,11 +38,12 @@ "license": "Apache-2.0", "dependencies": { "@apphosting/common": "*", - "@npmcli/promise-spawn": "^3.0.0", + "@npmcli/promise-spawn": "^3.0.0", "colorette": "^2.0.20", "commander": "^11.1.0", "npm-pick-manifest": "^9.0.0", - "ts-node": "^10.9.1" + "ts-node": "^10.9.1", + "yaml": "^2.3.4" }, "devDependencies": { "@types/commander": "*", diff --git a/scripts/verify-packages.js b/scripts/verify-packages.js new file mode 100644 index 000000000..3b4b7ef42 --- /dev/null +++ b/scripts/verify-packages.js @@ -0,0 +1,212 @@ +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const rootDir = path.resolve(__dirname, ".."); + +function moveFileSync(src, dest) { + try { + fs.renameSync(src, dest); + } catch (err) { + if (err.code === "EXDEV") { + fs.copyFileSync(src, dest); + fs.unlinkSync(src); + } else { + throw err; + } + } +} + +const packages = [ + { name: "@apphosting/common", path: "packages/@apphosting/common" }, + { name: "@apphosting/adapter-nextjs", path: "packages/@apphosting/adapter-nextjs" }, + { name: "@apphosting/adapter-angular", path: "packages/@apphosting/adapter-angular" }, + { name: "@apphosting/build", path: "packages/@apphosting/build" }, + { name: "@apphosting/create", path: "packages/@apphosting/create" }, + { name: "@apphosting/discover", path: "packages/@apphosting/discover" }, + { name: "create-next-on-firebase", path: "packages/create-next-on-firebase" }, + { name: "firebase-frameworks", path: "packages/firebase-frameworks" }, +]; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "package-verify-")); +console.log(`Temp dir for tarballs: ${tempDir}`); + +const tarballs = {}; + +try { + // 1. Pack built packages + const packagesToPack = packages.filter((pkg) => { + const hasDist = fs.existsSync(path.join(rootDir, pkg.path, "dist")); + if (!hasDist) { + console.log(`Skipping ${pkg.name} (not built)`); + } + return hasDist; + }); + const builtPackageNames = new Set(packagesToPack.map((p) => p.name)); + + for (const pkg of packagesToPack) { + const pkgPath = path.join(rootDir, pkg.path); + console.log(`Packing ${pkg.name}...`); + const output = execSync("npm pack --json", { cwd: pkgPath, encoding: "utf8" }); + const tarballName = JSON.parse(output)[0].filename; + const tarballPath = path.join(pkgPath, tarballName); + const destPath = path.join(tempDir, tarballName); + moveFileSync(tarballPath, destPath); + tarballs[pkg.name] = destPath; + } + + // 2. Define verification tasks + const tasks = [ + { + name: "@apphosting/common", + localDeps: [], + peerDeps: [], + checks: [{ type: "import", target: "@apphosting/common" }], + }, + { + name: "@apphosting/adapter-nextjs", + localDeps: ["@apphosting/common"], + peerDeps: ["next@~14.0.0", "react@~18.2.0", "react-dom@~18.2.0", "typescript@^5.2.0"], + checks: [ + { type: "import-binary", target: "@apphosting/adapter-nextjs/dist/bin/build.js" }, + { type: "import-binary", target: "@apphosting/adapter-nextjs/dist/bin/create.js" }, + ], + }, + { + name: "@apphosting/adapter-angular", + localDeps: ["@apphosting/common"], + peerDeps: [ + "@angular/core@~17.2.0", + "@angular-devkit/core@~17.2.0", + "@angular-devkit/architect@~0.1702.0", + "typescript@^5.2.0", + ], + checks: [ + { type: "import-binary", target: "@apphosting/adapter-angular/dist/bin/build.js" }, + { type: "import-binary", target: "@apphosting/adapter-angular/dist/bin/create.js" }, + ], + }, + { + name: "@apphosting/build", + localDeps: ["@apphosting/common"], + peerDeps: [], + checks: [ + { + type: "binary", + name: "apphosting-local-build", + cmd: "./node_modules/.bin/apphosting-local-build --help", + }, + ], + }, + { + name: "@apphosting/create", + localDeps: [], + peerDeps: [], + checks: [{ type: "binary", name: "create", cmd: "./node_modules/.bin/create --help" }], + }, + { + name: "@apphosting/discover", + localDeps: [], + peerDeps: [], + checks: [{ type: "binary", name: "discover", cmd: "./node_modules/.bin/discover --help" }], + }, + { + name: "create-next-on-firebase", + localDeps: [], + peerDeps: [], + checks: [ + { + type: "binary", + name: "create-next-on-firebase", + cmd: "./node_modules/.bin/create-next-on-firebase --help", + }, + ], + }, + { + name: "firebase-frameworks", + localDeps: [], + // Install peer deps to allow import verification + peerDeps: ["firebase-admin@^12.0.0", "firebase@^10.0.0", "sharp@^0.33.0"], + checks: [{ type: "import", target: "firebase-frameworks" }], + }, + ]; + + // 3. Run tasks + for (const task of tasks) { + if (!builtPackageNames.has(task.name)) { + console.log(`Skipping verification for ${task.name} (not built)`); + continue; + } + console.log(`\n========================================`); + console.log(`Running verification for ${task.name}`); + console.log(`========================================`); + + const taskTempDir = fs.mkdtempSync(path.join(tempDir, `task-${task.name.replace("/", "-")}-`)); + const testProjDir = path.join(taskTempDir, "test-project"); + fs.mkdirSync(testProjDir); + fs.writeFileSync( + path.join(testProjDir, "package.json"), + JSON.stringify({ + name: "test-project", + private: true, + type: "module", + }), + ); + + // Collect tarballs to install (self + localDeps) + const tarballsToInstall = [tarballs[task.name]]; + for (const dep of task.localDeps) { + tarballsToInstall.push(tarballs[dep]); + } + + const installArgs = [...task.peerDeps, ...tarballsToInstall].map((arg) => `"${arg}"`).join(" "); + console.log(`Installing dependencies for ${task.name}...`); + execSync(`npm install --no-audit --no-fund ${installArgs}`, { + cwd: testProjDir, + stdio: "inherit", + }); + + // Run checks + for (const check of task.checks) { + if (check.type === "binary") { + console.log(`Testing binary: ${check.name}...`); + try { + execSync(check.cmd, { cwd: testProjDir, stdio: "inherit" }); + console.log(`Binary ${check.name} passed.`); + } catch (error) { + console.error(`Binary ${check.name} failed!`); + throw error; + } + } else if (check.type === "import-binary") { + console.log(`Testing import of binary: ${check.target}...`); + try { + execSync(`node -e "import('${check.target}')"`, { cwd: testProjDir, stdio: "inherit" }); + console.log(`Binary import ${check.target} passed.`); + } catch (error) { + console.error(`Binary import ${check.target} failed!`); + throw error; + } + } else if (check.type === "import") { + console.log(`Testing import: ${check.target}...`); + try { + execSync(`node -e "import('${check.target}')"`, { cwd: testProjDir, stdio: "inherit" }); + console.log(`Import ${check.target} passed.`); + } catch (error) { + console.error(`Import ${check.target} failed!`); + throw error; + } + } + } + console.log(`Verification for ${task.name} passed.`); + } + + console.log("\nAll verifications finished successfully."); +} finally { + console.log("Cleaning up tarballs and task directories..."); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Failed to cleanup temp dir ${tempDir}:`, err); + } +}