#!/usr/bin/env bash # # Generates an application with create-app, runs its tests, checks dev, builds it with --tar, starts it in production # mode, deploys the resulting dist.tar.gz the way a server would and starts that copy, then retargets the native # modules for another platform. # Every package can build, typecheck and test in this repository and still produce an application that # does not start, because what a generated application installs is decided by published manifests rather than by the # workspace links everything resolves through here. # # The versions are given explicitly rather than resolved through a dist-tag. On a proxying registry a dist-tag is the # higher of the local and the upstream value, so `latest` there is not the `latest` a user would get — and during a # hotfix release, where the version being tested is deliberately older than the one upstream, it would resolve to a # version this run never produced. # # Usage: # scripts/smoke-create-app.sh \ # --registry http://localhost:4873 \ # --create-app-version 0.1.0-beta.12 \ # --template @nocobase/app-template-default@0.1.0-beta.12 \ # [--workdir DIR] [--timeout SECONDS] [--dialect DIALECT] [--config FILE] [--json] set -euo pipefail REGISTRY='' CREATE_APP_VERSION='' TEMPLATE='' WORKDIR='' TIMEOUT=420 APP_NAME='crm' DIALECT='' CONFIG='' JSON_OUTPUT=0 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" while [ $# -gt 0 ]; do case "$1" in --registry) REGISTRY="$2"; shift 2 ;; --create-app-version) CREATE_APP_VERSION="$2"; shift 2 ;; --template) TEMPLATE="$2"; shift 2 ;; --workdir) WORKDIR="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;; --dialect) DIALECT="$2"; shift 2 ;; --config) CONFIG="$2"; shift 2 ;; --json) JSON_OUTPUT=1; shift ;; --app-name) APP_NAME="$2"; shift 2 ;; -h|--help) sed -n '2,24p' "$0"; exit 0 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac done if [ -z "$REGISTRY" ] || [ -z "$CREATE_APP_VERSION" ] || [ -z "$TEMPLATE" ]; then echo "::error::--registry, --create-app-version and --template are all required" >&2 exit 2 fi if [ -z "$WORKDIR" ]; then WORKDIR="$(mktemp -d)" fi mkdir -p "$WORKDIR" WORKDIR="$(cd "$WORKDIR" && pwd)" if [ -n "$(ls -A "$WORKDIR")" ]; then echo "Test workdir must be empty: $WORKDIR" >&2 exit 2 fi if ! [[ "$APP_NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || ! [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then echo "Invalid app name or timeout" >&2 exit 2 fi if [ -n "$CONFIG" ]; then CONFIG=$(node -e 'console.log(require("node:path").resolve(process.argv[1]))' "$CONFIG") fi APP_DIR="$WORKDIR/$APP_NAME" DEV_LOG="$WORKDIR/dev.log" TEST_LOG="$WORKDIR/test.log" BUILD_LOG="$WORKDIR/build.log" START_LOG="$WORKDIR/start.log" # A freshly published version is minutes old, and pnpm refuses packages younger than the configured minimum release # age. Both the create-app download and the generated application's own install have to see this. export PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 # Both spellings, because the two tools disagree about which one exists. pnpm 11 reads PNPM_CONFIG_ and # ignores npm_config_* entirely, while npm reads npm_config_* and knows nothing about the pnpm form. `pnpm create` # resolves through pnpm, and create-app shells out to both, so setting only one silently leaves half of this pointed # at whatever registry the machine is configured for. export PNPM_CONFIG_REGISTRY="$REGISTRY" export npm_config_registry="$REGISTRY" export NPM_CONFIG_REGISTRY="$REGISTRY" # create-app reads this when --registry is not given; passing both keeps the generated application's install pointed # at the same place even if the flag handling changes. export NOCOBASE_REGISTRY="$REGISTRY" # An override that silently did not apply would test packages from the wrong registry and report success, so it is # confirmed rather than assumed. for tool in pnpm npm; do resolved="$("$tool" config get registry 2>/dev/null | tr -d '\r' | tail -1)" case "$resolved" in "$REGISTRY"|"$REGISTRY/") ;; *) echo "::error::$tool resolves the registry to '$resolved', not '$REGISTRY'" >&2 exit 1 ;; esac done echo "::group::Create the application" echo "registry: $REGISTRY" echo "create-app: $CREATE_APP_VERSION" echo "template: $TEMPLATE" echo "workdir: $WORKDIR" cd "$WORKDIR" CREATE_ARGS=("@nocobase/app@$CREATE_APP_VERSION" "$APP_NAME" "--registry=$REGISTRY" "--template=$TEMPLATE") if [ "$JSON_OUTPUT" = 1 ]; then pnpm create "${CREATE_ARGS[@]}" --json > "$WORKDIR/create.json" # --create-app-version may name a release from before create-app printed the application CLI's envelope, whose # result was flat: `ok` is then absent and `dependenciesInstalled` sits at the top level. node -e 'const r=JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if(!(r.ok ?? r.status==="success") || !(r.result ?? r).dependenciesInstalled) process.exit(1)' "$WORKDIR/create.json" else pnpm create "${CREATE_ARGS[@]}" fi echo "::endgroup::" if [ ! -d "$APP_DIR/node_modules" ]; then echo "::error::create-app finished without installing dependencies into $APP_DIR" exit 1 fi # Creation deliberately leaves the application unconfigured; `config init` is what writes this file. if [ -e "$APP_DIR/config.yml" ]; then echo "::error::create-app wrote config.yml, which config init owns" exit 1 fi cd "$APP_DIR" echo "::group::Configure the application with pnpm nocobase config init" # Which dialects an application can run on is decided by the driver it depends on, so a non-SQLite run installs one # first — exactly the two commands the documentation gives a user switching databases. if [ -n "$DIALECT" ] && [ "$DIALECT" != "sqlite" ]; then pnpm add "@nocobase/db-$DIALECT" fi CONFIG_INIT_ARGS=("--json") if [ -n "$DIALECT" ]; then CONFIG_INIT_ARGS+=("--dialect=$DIALECT"); fi pnpm nocobase config init "${CONFIG_INIT_ARGS[@]}" > "$WORKDIR/config-init.json" node -e 'const r=JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if(!r.ok) process.exit(1)' "$WORKDIR/config-init.json" if [ ! -f "$APP_DIR/config.yml" ]; then echo "::error::pnpm nocobase config init reported success without writing config.yml" exit 1 fi if [ -n "$CONFIG" ]; then node "$SCRIPT_DIR/smoke-database-config.mjs" "$APP_DIR/config.yml" "$CONFIG" "$DIALECT" fi echo "::endgroup::" echo "::group::Check the configuration with pnpm nocobase config check" # Loads the configuration through the application and, for anything but SQLite, connects to the database — so a # broken configuration fails here with a named cause rather than as a startup that never becomes ready. if ! pnpm nocobase config check --json > "$WORKDIR/config-check.json"; then cat "$WORKDIR/config-check.json" echo "::error::pnpm nocobase config check reported a problem with the generated configuration" exit 1 fi echo "::endgroup::" echo "::group::Synchronize NocoBase package Skills" # create-app reports a synchronization failure as a warning. Exercise the command # explicitly so an invalid published Skill cannot pass this smoke test. pnpm nocobase skills sync echo "::endgroup::" echo "::group::Test the application with pnpm test" if ! pnpm test 2>&1 | tee "$TEST_LOG"; then echo "::endgroup::" echo "::error::pnpm test failed" exit 1 fi echo "::endgroup::" echo "::group::Boot the application with pnpm dev" # `pnpm dev` does not exit on success — it holds the client and server processes open — so it runs in the background # and is judged by what it prints. The template's dev entry only prints this line after both the Vite dev server and # the application server's /api/healthz have answered, and it exits non-zero when either fails to come up or a child # dies, so the two outcomes this loop watches for are the two the script actually has. READY_MARKER='App dev server ready' # Every request here goes to the loopback address the application was told to listen on, so a proxy configured in # the shell must not see it: routed through one, a refused connection becomes a timeout and a healthy server an # unreachable one. `--noproxy '*'` is on every curl for that reason. # Job control, so the background job becomes its own process group and the whole tree can be signalled at the end. # Killing the pnpm process alone would leave vite and tsx running, and the job would hang waiting on them. `setsid` # would do the same thing but does not exist on macOS, where this script is also run by hand. set -m NOCOBASE_STRICT_STARTUP=true pnpm dev > "$DEV_LOG" 2>&1 & APP_PID=$! set +m stop_app() { # Check the group even if pnpm has already exited: its children may still own the port. if kill -0 -"$APP_PID" 2>/dev/null; then kill -TERM -"$APP_PID" 2>/dev/null || true for _ in $(seq 1 20); do kill -0 -"$APP_PID" 2>/dev/null || break sleep 0.5 done kill -KILL -"$APP_PID" 2>/dev/null || true fi wait "$APP_PID" 2>/dev/null || true } trap stop_app EXIT trap 'exit 130' INT trap 'exit 143' TERM # Asks the OS for an available loopback port rather than assuming the development or default port is free. free_port() { node --input-type=module -e ' import net from "node:net"; const server = net.createServer(); server.listen(0, "127.0.0.1", () => { console.log(server.address().port); server.close(); }); ' } # Waits for a production process started in the background as $APP_PID to answer /api/healthz, then checks that it # serves its homepage, is still alive, and discovered its jobs. Shared by `pnpm start` and the deployed archive so # the two are judged by the same evidence. Arguments: label for messages, base URL, log file. wait_for_production() { local label="$1" url="$2" log="$3" local ready=0 exited=0 deadline next_progress http_status='' echo "Waiting up to ${TIMEOUT}s for $url/api/healthz" deadline=$((SECONDS + TIMEOUT)) next_progress=$SECONDS while [ "$SECONDS" -lt "$deadline" ]; do if ! kill -0 "$APP_PID" 2>/dev/null; then exited=1 break fi if http_status=$(curl -sS --noproxy '*' --max-time 2 --write-out '%{http_code}' "$url/api/healthz" -o "$WORKDIR/health.json" 2>/dev/null) \ && [ "$http_status" = '200' ] \ && node -e 'const fs = require("node:fs"); try { process.exit(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).ok === true ? 0 : 1); } catch { process.exit(1); }' "$WORKDIR/health.json"; then ready=1 break fi if [ "$SECONDS" -ge "$next_progress" ]; then echo "Still waiting for production health: HTTP ${http_status:-000}; $((deadline - SECONDS))s remaining" # Progress diagnostics must not abort the readiness check when the log is temporarily unavailable. tail -n 10 "$log" || true next_progress=$((SECONDS + 15)) fi sleep 1 done if [ "$ready" != "1" ]; then echo "::endgroup::" if [ "$exited" = "1" ]; then echo "::error::$label exited before the application became ready" else echo "::error::$label did not become ready within ${TIMEOUT}s" fi cat "$log" exit 1 fi if ! curl -fsS --noproxy '*' --max-time 30 "$url/" -o /dev/null || ! kill -0 "$APP_PID" 2>/dev/null; then echo "::endgroup::" echo "::error::The production application started by $label did not serve its homepage or exited after becoming ready" cat "$log" exit 1 fi if grep -qF 'Failed to load job from ' "$log"; then echo "::error::Job discovery failed during production startup ($label)" cat "$log" exit 1 fi cat "$log" echo "Production application started by $label is serving at $url/" } READY=0 EXITED=0 for _ in $(seq 1 "$TIMEOUT"); do if grep -qF "$READY_MARKER" "$DEV_LOG" 2>/dev/null; then READY=1 break fi if ! kill -0 "$APP_PID" 2>/dev/null; then EXITED=1 break fi sleep 1 done if [ "$READY" != "1" ]; then echo "::endgroup::" if [ "$EXITED" = "1" ]; then echo "::error::pnpm dev exited before the application became ready" else echo "::error::pnpm dev did not become ready within ${TIMEOUT}s" fi echo "----- dev.log -----" cat "$DEV_LOG" exit 1 fi # The readiness line is followed by the URL the application is served on, and the port is not fixed: the dev entry # falls back to another one when its preferred port is taken. Reading it back is both how the URL is discovered and a # check that the line means what it says. APP_URL="$(grep -F 'Local:' "$DEV_LOG" | tail -1 | awk '{print $2}')" if [ -z "$APP_URL" ]; then echo "::endgroup::" echo "::error::The application reported ready but printed no URL" cat "$DEV_LOG" exit 1 fi if grep -qF 'Failed to load job from ' "$DEV_LOG"; then echo "::error::Job discovery failed during development startup" cat "$DEV_LOG" exit 1 fi echo "Application is serving at $APP_URL" if ! curl -fsS --noproxy '*' --max-time 30 "$APP_URL" -o /dev/null; then echo "::endgroup::" echo "::error::$APP_URL did not answer after the application reported ready" cat "$DEV_LOG" exit 1 fi echo "::endgroup::" # Dev must be gone before building or probing production, otherwise it can answer requests on behalf of a broken start. stop_app trap - EXIT echo "::group::Build the application with pnpm build --tar" # --tar is what a deployment build runs: the archive it produces is deployed below the way a server would deploy it. if ! pnpm build --tar 2>&1 | tee "$BUILD_LOG"; then echo "::endgroup::" echo "::error::pnpm build failed" exit 1 fi echo "::endgroup::" echo "::group::Boot the application with pnpm start" START_PORT=$(free_port) # Reuse the generated application's public path from dev; the default template mounts at /main, not at /. APP_PATH=$(node -e 'console.log(new URL(process.argv[1]).pathname.replace(/\/+$/, ""))' "$APP_URL") START_URL="http://127.0.0.1:$START_PORT$APP_PATH" # Create the log before forking so the progress loop cannot race the child's output redirection. : > "$START_LOG" set -m NOCOBASE_STRICT_STARTUP=true APP_SERVER_HOST=127.0.0.1 APP_SERVER_PORT="$START_PORT" pnpm start > "$START_LOG" 2>&1 & APP_PID=$! set +m trap stop_app EXIT wait_for_production 'pnpm start' "$START_URL" "$START_LOG" echo "::endgroup::" # The production process must be gone before the deployed copy starts, so a request cannot be answered by the wrong one. stop_app trap - EXIT echo "::group::Deploy the dist.tar.gz archive" # What `pnpm build --tar` packed is what a server receives. Deploying it into an empty directory the way the deployment # guide describes — extract, add config.yml, add storage, start `node ./dist/server/standalone.js` — is the only check # that the archive is complete: a file that `pnpm start` finds in the application checkout and the archive omits fails # only here. ARCHIVE="$APP_DIR/storage/exports/dist.tar.gz" ARCHIVE_LIST="$WORKDIR/archive.txt" DEPLOY_DIR="$WORKDIR/deploy" DEPLOY_LOG="$WORKDIR/deploy.log" if [ ! -f "$ARCHIVE" ]; then echo "::endgroup::" echo "::error::pnpm build --tar did not produce storage/exports/dist.tar.gz" exit 1 fi if ! tar -tzf "$ARCHIVE" > "$ARCHIVE_LIST"; then echo "::endgroup::" echo "::error::storage/exports/dist.tar.gz cannot be read to the end" exit 1 fi for entry in 'config\.example\.yml' 'dist/package\.json' 'dist/server/standalone\.js'; do if ! grep -qE "^(\./)?${entry}$" "$ARCHIVE_LIST"; then echo "::endgroup::" echo "::error::dist.tar.gz does not contain ${entry//\\/}" head -n 30 "$ARCHIVE_LIST" exit 1 fi done # The archive carries code and an example configuration, never the real configuration or data of the machine that # built it: a config.yml holds secrets, and storage holds the database of the checkout. if grep -qE '^(\./)?(config\.yml|\.env|storage(/|$))' "$ARCHIVE_LIST"; then echo "::endgroup::" echo "::error::dist.tar.gz contains runtime configuration or data that must stay out of a deployment package" grep -E '^(\./)?(config\.yml|\.env|storage(/|$))' "$ARCHIVE_LIST" exit 1 fi echo "Archive holds $(wc -l < "$ARCHIVE_LIST" | tr -d ' ') entries" mkdir "$DEPLOY_DIR" tar -xzf "$ARCHIVE" -C "$DEPLOY_DIR" # The generated application's config.yml is reused as the deployment's configuration: the same settings and secrets # `pnpm start` ran with, placed beside dist where the deployment guide puts it and named through APP_CONFIG_FILE. # The sqlite path in it is relative, so it resolves against this deployment root: the extracted copy initialises its # own storage rather than opening the database `pnpm start` used. cp "$APP_DIR/config.yml" "$DEPLOY_DIR/config.yml" mkdir -p "$DEPLOY_DIR/storage" # The deployment guide checks the configuration from inside dist before starting, with the build's own CLI. This is the # only place dist/cli is run at all, so a CLI that builds but does not run in a deployment fails here. if ! (cd "$DEPLOY_DIR/dist" && APP_CONFIG_FILE="$DEPLOY_DIR/config.yml" pnpm nocobase config check --json > "$WORKDIR/deploy-config-check.json"); then cat "$WORKDIR/deploy-config-check.json" echo "::error::pnpm nocobase config check failed in the deployed archive" exit 1 fi DEPLOY_PORT=$(free_port) DEPLOY_URL="http://127.0.0.1:$DEPLOY_PORT$APP_PATH" : > "$DEPLOY_LOG" cd "$DEPLOY_DIR" set -m # The archive is not tied to a mount path and carries none, so the server is given one the way a deployment gives it: # the same path the checkout served. NODE_ENV=production NOCOBASE_STRICT_STARTUP=true APP_CONFIG_FILE="$DEPLOY_DIR/config.yml" \ APP_BASE_PATH="$APP_PATH" APP_SERVER_HOST=127.0.0.1 APP_SERVER_PORT="$DEPLOY_PORT" \ node ./dist/server/standalone.js > "$DEPLOY_LOG" 2>&1 & APP_PID=$! set +m cd "$APP_DIR" trap stop_app EXIT wait_for_production 'the deployed archive' "$DEPLOY_URL" "$DEPLOY_LOG" echo "::endgroup::" stop_app trap - EXIT echo "::group::Retarget native modules for another platform" # A deployment build targets the server it is shipped to, which is rarely the machine that builds it. Retargeting the # installed tree for a platform this machine is not exercises the cross-platform path — fetching the target's binaries # and dropping the others — and records what was targeted. Last, because the tree no longer runs here afterwards. OTHER_TARGET=$(node -e 'console.log(process.platform === "linux" && process.arch === "x64" ? "linux-arm64" : "linux-x64")') RETARGET_LOG="$WORKDIR/retarget.log" echo "Retargeting for $OTHER_TARGET" if ! pnpm nocobase dist retarget --target "$OTHER_TARGET" --node-version 24 2>&1 | tee "$RETARGET_LOG"; then echo "::endgroup::" echo "::error::Retargeting native modules for $OTHER_TARGET failed" exit 1 fi if ! node --input-type=module -e ' import fs from "node:fs"; import path from "node:path"; const [distDir, target] = process.argv.slice(1); const [platform, arch] = target.split("-"); const recorded = JSON.parse(fs.readFileSync(path.join(distDir, "package.json"), "utf8")).nocobase?.buildTarget; if (recorded?.platform !== platform || recorded?.arch !== arch || recorded?.nodeMajor !== 24) { console.error(`dist/package.json records build target ${JSON.stringify(recorded)}, expected ${target} for Node 24`); process.exit(1); } // A binary whose name identifies another platform is dead weight the retarget promised to remove. const wanted = `${platform}-${arch}`; const foreign = []; const walk = (directory) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) walk(file); else if (entry.name.endsWith(".node") && /(darwin|linux|linuxmusl|win32|android)-/u.test(entry.name) && !entry.name.includes(wanted)) foreign.push(path.relative(distDir, file)); } }; walk(path.join(distDir, "node_modules")); if (foreign.length > 0) { console.error(`Binaries for other platforms remain after retargeting for ${target}:\n ${foreign.join("\n ")}`); process.exit(1); } console.log(`dist/package.json records ${target} (Node ABI ${recorded.nodeAbi}) and no binary names another platform.`); ' "$APP_DIR/dist" "$OTHER_TARGET"; then echo "::endgroup::" echo "::error::The retargeted build is not consistent with $OTHER_TARGET" exit 1 fi echo "::endgroup::" echo "create-app smoke test passed: $TEMPLATE passed test, dev, build, start, archive deployment, and retarget for $OTHER_TARGET."