|
| 1 | +name: CommandBox install smoke (ForgeBox path) |
| 2 | + |
| 3 | +# Durability net for the ForgeBox/CommandBox install path (issues #3173, #3174, |
| 4 | +# #3176, #3177). The published ForgeBox channel was repaired across a campaign: |
| 5 | +# the base template is now regenerated from cli/lucli/templates/app/ as the |
| 6 | +# single source of truth (#3176) with build-time placeholder substitution |
| 7 | +# (#3173) and db/ retained (#3174), and wheels-core lands flat (#3177). |
| 8 | +# |
| 9 | +# Those fixes are easy to silently regress — a placeholder reintroduced into a |
| 10 | +# config override, a db/ exclude added back to the base .gitignore, a prepare |
| 11 | +# script re-pointed at the demo app — and ForgeBox publishing is stable-only |
| 12 | +# and asynchronous, so the breakage only surfaces in production weeks later |
| 13 | +# (which is exactly how the channel rotted the first time). |
| 14 | +# |
| 15 | +# This leg closes that gap by exercising the FIXED build pipeline end to end on |
| 16 | +# every PR that touches it: |
| 17 | +# |
| 18 | +# 1. Build the base + core packages FROM THE TREE |
| 19 | +# (tools/build/scripts/prepare-base.sh + prepare-core.sh) — i.e. the |
| 20 | +# artifact this PR would publish, NOT the lagging ForgeBox copy. |
| 21 | +# 2. Stage them the way `box install wheels-base-template` lands them: the |
| 22 | +# base scaffold at the app root, wheels-core at vendor/wheels/ per the |
| 23 | +# box.json installPaths. |
| 24 | +# 3. Boot the app under CommandBox (the ortussolutions/commandbox image — |
| 25 | +# CommandBox is the runtime on this path, not LuCLI) with ZERO manual |
| 26 | +# edits and assert the install->serve journey: |
| 27 | +# - dev: server starts no-edit, GET / is 200, /wheels/info is 200 |
| 28 | +# (the dev whitelist), clean URLs (/main/index) work, db/ ships. |
| 29 | +# - prod: tools/ci/smoke-env.sh passes its 6 reload-gate / no-trace |
| 30 | +# probes (the same script the Smoke workflow drives). |
| 31 | +# |
| 32 | +# IMPORTANT — ordering within the campaign: this leg tests the FIXED pipeline. |
| 33 | +# The template keystone (#3176, build base template from cli/lucli/templates/app) |
| 34 | +# is a sibling PR that is NOT yet on develop. Until it merges, develop's |
| 35 | +# prepare-base.sh still copies the repo-root demo app and ships |
| 36 | +# "cfengine":"|cfmlEngine|" — so `box server start` hard-errors with "Invalid |
| 37 | +# slug detected" and the connectivity probe fails (status 000). That is the |
| 38 | +# defect this leg is meant to catch; the probes are deliberately NOT weakened to |
| 39 | +# pass on the unfixed tree. Once the keystone lands on develop this job goes |
| 40 | +# green. The wheels-core fixes (#3177/#3178/#3179) are already on develop. |
| 41 | +# |
| 42 | +# Verified locally (2026-06-12, CommandBox 6.3.3 / ortussolutions/commandbox: |
| 43 | +# latest, keystone composed): all six smoke-env.sh probes PASS in production |
| 44 | +# and the dev journey (GET / 200, /wheels/info 200, /main/index 200, db/ |
| 45 | +# present) passes with no edits. Booting the unfixed develop artifact exits 1 |
| 46 | +# with "Invalid slug detected" — the gate is real. |
| 47 | + |
| 48 | +on: |
| 49 | + pull_request: |
| 50 | + branches: |
| 51 | + - develop |
| 52 | + paths: |
| 53 | + # The build pipeline that produces the packages. |
| 54 | + - 'tools/build/**' |
| 55 | + # The single source of truth the base template is regenerated from. |
| 56 | + - 'cli/lucli/templates/app/**' |
| 57 | + # The probe harness this leg drives in production. |
| 58 | + - 'tools/ci/smoke-env.sh' |
| 59 | + # The workflow itself. |
| 60 | + - '.github/workflows/commandbox-install-smoke.yml' |
| 61 | + |
| 62 | +permissions: |
| 63 | + contents: read |
| 64 | + |
| 65 | +jobs: |
| 66 | + commandbox-install-smoke: |
| 67 | + name: "box install -> serve (CommandBox)" |
| 68 | + runs-on: ubuntu-latest |
| 69 | + timeout-minutes: 25 |
| 70 | + # CommandBox is the runtime on the ForgeBox install path. Run the whole job |
| 71 | + # inside the official image so `box` is on PATH exactly as a real CommandBox |
| 72 | + # user has it — no host install, mirroring how release.yml gets CommandBox |
| 73 | + # for ForgeBox publishing (its "Install CommandBox" step), just containerized |
| 74 | + # so the box server boots in the same userland it serves from. |
| 75 | + container: |
| 76 | + image: ortussolutions/commandbox:latest |
| 77 | + env: |
| 78 | + # Throwaway values for the CI app; not secrets. |
| 79 | + SMOKE_RELOAD_PASSWORD: "wheels-dev" |
| 80 | + # Pin the version the prepare scripts stamp into the artifacts. |
| 81 | + PKG_VERSION: "0.0.0-cismoke" |
| 82 | + steps: |
| 83 | + - uses: actions/checkout@v5 |
| 84 | + |
| 85 | + # curl + jq are the only host tools the probes need; the CommandBox image |
| 86 | + # is Debian-based but minimal. Install defensively (no-op if present). |
| 87 | + - name: Ensure curl is available |
| 88 | + run: | |
| 89 | + command -v curl >/dev/null 2>&1 || { |
| 90 | + apt-get update -y && apt-get install -y --no-install-recommends curl ca-certificates |
| 91 | + } |
| 92 | + curl --version | head -1 |
| 93 | +
|
| 94 | + # Build BOTH packages from the working tree — this is the output of the |
| 95 | + # pipeline this PR would publish, so a regression in either prepare script |
| 96 | + # fails here, not silently on ForgeBox weeks later. (Snapshot params: |
| 97 | + # branch=develop so the version-number handling matches a snapshot build.) |
| 98 | + - name: Build base + core packages from the tree |
| 99 | + run: | |
| 100 | + set -e |
| 101 | + bash tools/build/scripts/prepare-base.sh "${PKG_VERSION}" "develop" "${{ github.run_number }}" "false" |
| 102 | + bash tools/build/scripts/prepare-core.sh "${PKG_VERSION}" "develop" "${{ github.run_number }}" "false" |
| 103 | + test -d build-wheels-base || { echo "::error::prepare-base.sh produced no build-wheels-base/"; exit 1; } |
| 104 | + test -d build-wheels-core/wheels || { echo "::error::prepare-core.sh produced no build-wheels-core/wheels/"; exit 1; } |
| 105 | +
|
| 106 | + # Structural guards on the prepared base artifact (cheap, pre-boot). These |
| 107 | + # pin the three packaging defects the campaign repaired so a regression is |
| 108 | + # named precisely instead of surfacing as an opaque boot failure. |
| 109 | + - name: Assert prepared base artifact is publish-clean |
| 110 | + run: | |
| 111 | + set -e |
| 112 | + BASE=build-wheels-base |
| 113 | +
|
| 114 | + # #3174: db/ must ship (JDBC needs a parent dir; the base .gitignore |
| 115 | + # must not strip it). The keeper marker is enough — only generated |
| 116 | + # *.db/*.sqlite files are ignored. |
| 117 | + test -d "$BASE/db" || { echo "::error::#3174 regression: base artifact has no db/ directory"; exit 1; } |
| 118 | + test -f "$BASE/db/.keep" || { echo "::error::#3174 regression: db/.keep missing — empty dir will not survive packaging"; exit 1; } |
| 119 | +
|
| 120 | + # #3173: NO unsubstituted placeholders may remain in the files a raw |
| 121 | + # `box install` consumes. |cfmlEngine| in server.json is the one that |
| 122 | + # hard-errors `box server start`; the {{...}} app tokens are the ones |
| 123 | + # that break boot/config. (app/snippets/*.txt code-gen tokens are |
| 124 | + # excluded — those ship verbatim by design.) |
| 125 | + if grep -rnE '\|(appName|cfmlEngine|datasourceName|reloadPassword)\|' \ |
| 126 | + "$BASE/server.json" "$BASE/config" "$BASE/app/views" 2>/dev/null; then |
| 127 | + echo "::error::#3173 regression: legacy |placeholder| token survived into the prepared base artifact" |
| 128 | + exit 1 |
| 129 | + fi |
| 130 | + if grep -rnE '\{\{(appName|cfmlEngine|datasourceName|reloadPassword|luceeAdminPassword)\}\}' \ |
| 131 | + "$BASE/server.json" "$BASE/config" "$BASE/app/views" 2>/dev/null; then |
| 132 | + echo "::error::#3173 regression: {{placeholder}} token survived into the prepared base artifact" |
| 133 | + exit 1 |
| 134 | + fi |
| 135 | +
|
| 136 | + # The root route targets main##index — the default controller/view must |
| 137 | + # be generated (the LuCLI template does not carry them statically), else |
| 138 | + # GET / is Wheels.ViewNotFound on every install. |
| 139 | + test -f "$BASE/app/controllers/Main.cfc" || { echo "::error::default Main.cfc missing — GET / would 404"; exit 1; } |
| 140 | + test -f "$BASE/app/views/main/index.cfm" || { echo "::error::default main/index.cfm missing — GET / would 404"; exit 1; } |
| 141 | +
|
| 142 | + echo "Prepared base artifact is publish-clean (db/ present, no placeholders, root view present)." |
| 143 | +
|
| 144 | + # Stage the app exactly as `box install wheels-base-template` lands it: the |
| 145 | + # base scaffold at the app root + wheels-core resolved into vendor/wheels/ |
| 146 | + # per the box.json installPaths. Installing from the locally built core |
| 147 | + # (not ForgeBox) is the whole point — it exercises THIS PR's pipeline |
| 148 | + # output rather than the lagging published copy. |
| 149 | + - name: Stage the installed app (base + local wheels-core) |
| 150 | + run: | |
| 151 | + set -e |
| 152 | + APP="${RUNNER_TEMP:-/tmp}/cb-app" |
| 153 | + rm -rf "$APP"; mkdir -p "$APP" |
| 154 | + cp -r build-wheels-base/. "$APP/" |
| 155 | + rm -rf "$APP/vendor/wheels"; mkdir -p "$APP/vendor/wheels" |
| 156 | + cp -r build-wheels-core/wheels/. "$APP/vendor/wheels/" |
| 157 | + # postInstall copies env.example -> .env on a real install; mimic it. |
| 158 | + [ -f "$APP/env.example" ] && cp "$APP/env.example" "$APP/.env" |
| 159 | + echo "APP_DIR=$APP" >> "$GITHUB_ENV" |
| 160 | + echo "Staged app at $APP" |
| 161 | + ls -la "$APP" |
| 162 | +
|
| 163 | + # --- Development journey: the user's actual first experience. ----------- |
| 164 | + # Boot with NO edits (dev is the template default) and assert the install |
| 165 | + # ->serve journey holds. /wheels/info is 200 in development (the documented |
| 166 | + # dev whitelist, #2988), so this leg asserts 200 here and the clean 404 is |
| 167 | + # asserted by the production smoke-env.sh leg below. |
| 168 | + - name: Start CommandBox server (development) |
| 169 | + run: | |
| 170 | + set -e |
| 171 | + cd "$APP_DIR" |
| 172 | + box server start port=8080 host=0.0.0.0 openbrowser=false || true |
| 173 | + # Readiness via curl-retry (no sleep): accept the first byte. |
| 174 | + curl -s -o /dev/null --retry 40 --retry-delay 3 --retry-all-errors \ |
| 175 | + --retry-connrefused --max-time 10 "http://localhost:8080/" \ |
| 176 | + || { echo "::error::server did not bind in development"; box server log || true; exit 1; } |
| 177 | +
|
| 178 | + - name: Assert install->serve journey (development) |
| 179 | + run: | |
| 180 | + set -e |
| 181 | + FAILS=0 |
| 182 | + probe() { # name url expect must |
| 183 | + local name="$1" url="$2" expect="$3" must="$4" body status |
| 184 | + body=$(mktemp) |
| 185 | + status=$(curl -s -o "$body" -w '%{http_code}' --max-time 30 "$url") |
| 186 | + if [ "$status" != "$expect" ]; then |
| 187 | + echo "FAIL $name: expected $expect got $status ($url)" |
| 188 | + echo " body_head: $(head -c 200 "$body" | tr '\n' ' ')" |
| 189 | + FAILS=$((FAILS+1)) |
| 190 | + elif [ -n "$must" ] && ! grep -qiE "$must" "$body"; then |
| 191 | + echo "FAIL $name: status $expect ok but body missing /$must/ ($url)" |
| 192 | + echo " body_head: $(head -c 200 "$body" | tr '\n' ' ')" |
| 193 | + FAILS=$((FAILS+1)) |
| 194 | + else |
| 195 | + echo "PASS $name ($status)" |
| 196 | + fi |
| 197 | + rm -f "$body" |
| 198 | + } |
| 199 | + # GET / renders the welcome page (200, no manual edits). |
| 200 | + probe "root-welcome" "http://localhost:8080/" "200" "Wheels" |
| 201 | + # /wheels/info is the public component, 200 in development (#2988). |
| 202 | + probe "wheels-info-dev-200" "http://localhost:8080/wheels/info" "200" "System Information" |
| 203 | + # Clean URLs: controller/action route resolves (rewrites work). |
| 204 | + probe "clean-url-main" "http://localhost:8080/main/index" "200" "" |
| 205 | + if [ "$FAILS" -ne 0 ]; then |
| 206 | + echo "::error::development install->serve journey failed ($FAILS probe(s))" |
| 207 | + exit 1 |
| 208 | + fi |
| 209 | + echo "development journey: all probes passed" |
| 210 | +
|
| 211 | + - name: Stop CommandBox server (development) |
| 212 | + if: always() |
| 213 | + run: | |
| 214 | + cd "$APP_DIR" 2>/dev/null && box server stop 2>/dev/null || true |
| 215 | +
|
| 216 | + # --- Production journey: the non-dev reload-gate / no-trace probes. ------ |
| 217 | + # Reuse tools/ci/smoke-env.sh (the same harness the Smoke workflow drives) |
| 218 | + # against a production boot. environment.cfm hardcodes set(environment=...), |
| 219 | + # so flip it to production before boot (the app cold-boots into it). The |
| 220 | + # reload password is read from env("WHEELS_RELOAD_PASSWORD") in settings.cfm |
| 221 | + # and commandbox-dotenv loads .env at boot, so the password must live in |
| 222 | + # .env (a container -e var is overridden by .env on this path) for probe 6 |
| 223 | + # (authorized reload must 302) to be meaningful. |
| 224 | + - name: Switch app to production |
| 225 | + run: | |
| 226 | + set -e |
| 227 | + cd "$APP_DIR" |
| 228 | + sed -i 's/set(environment="[a-z]*")/set(environment="production")/' config/environment.cfm |
| 229 | + grep -qF 'set(environment="production")' config/environment.cfm || { |
| 230 | + echo "::error::environment.cfm substitution failed — pattern drift?"; exit 1; } |
| 231 | + # Reload password into .env so settings.cfm's env() read + the smoke |
| 232 | + # probe agree. (commandbox-dotenv wins over container env on this path.) |
| 233 | + if [ -f .env ]; then |
| 234 | + sed -i 's/^WHEELS_RELOAD_PASSWORD=.*/WHEELS_RELOAD_PASSWORD='"${SMOKE_RELOAD_PASSWORD}"'/' .env |
| 235 | + grep -q '^WHEELS_RELOAD_PASSWORD=' .env || echo "WHEELS_RELOAD_PASSWORD=${SMOKE_RELOAD_PASSWORD}" >> .env |
| 236 | + else |
| 237 | + printf 'WHEELS_RELOAD_PASSWORD=%s\n' "${SMOKE_RELOAD_PASSWORD}" > .env |
| 238 | + fi |
| 239 | + grep -n 'set(environment' config/environment.cfm |
| 240 | + grep -n '^WHEELS_RELOAD_PASSWORD' .env |
| 241 | +
|
| 242 | + - name: Start CommandBox server (production) |
| 243 | + run: | |
| 244 | + set -e |
| 245 | + cd "$APP_DIR" |
| 246 | + box server start port=8080 host=0.0.0.0 openbrowser=false || true |
| 247 | + curl -s -o /dev/null --retry 40 --retry-delay 3 --retry-all-errors \ |
| 248 | + --retry-connrefused --max-time 10 "http://localhost:8080/" \ |
| 249 | + || { echo "::error::server did not bind in production"; box server log || true; exit 1; } |
| 250 | +
|
| 251 | + - name: Run smoke-env.sh probes (production) |
| 252 | + run: | |
| 253 | + BASE_URL="http://localhost:8080" SMOKE_ENV="production" \ |
| 254 | + SMOKE_RELOAD_PASSWORD="${SMOKE_RELOAD_PASSWORD}" \ |
| 255 | + bash tools/ci/smoke-env.sh |
| 256 | +
|
| 257 | + - name: Debug server log |
| 258 | + if: failure() |
| 259 | + run: | |
| 260 | + cd "$APP_DIR" 2>/dev/null && box server log 2>/dev/null | tail -200 || true |
| 261 | +
|
| 262 | + - name: Stop CommandBox server (production) |
| 263 | + if: always() |
| 264 | + run: | |
| 265 | + cd "$APP_DIR" 2>/dev/null && box server stop 2>/dev/null || true |
0 commit comments