From f8c9ba38bf58d2164b925c19fb0cb2f03721e798 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Fri, 11 Sep 2026 23:50:34 +0700 Subject: [PATCH 1/5] feat: say what scaffold new is doing, not what pnpm is doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating a project handed the terminal several minutes of a package manager's progress bars, through which the one line that mattered — which application is being generated — never appeared at all. It now prints six step lines and ends with the commands to run next. The technique is pnpm_install's, which has captured its own output and shown it only on failure since it was written; run_quietly is that, named, so the adapter generators, post-generate hooks and service drivers can use it too. A failing step still prints everything. SCAFFOLD_VERBOSE=1 passes output through for a run that hangs rather than fails, where there is otherwise nothing to look at. --- CONTRIBUTING.md | 19 +++++++++++++++++++ README.md | 4 ++++ lib/adapter.sh | 15 ++++++++------- lib/log.sh | 35 +++++++++++++++++++++++++++++++++++ lib/project.sh | 2 ++ lib/service.sh | 23 ++++++++++++++--------- scaffold | 9 +++++++++ tests/new-project.bats | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 123 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c531335..4a5ec67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,25 @@ writing decides whether anything has to be done afterwards. Conventional Commits, enforced by lefthook at `commit-msg`. `feat:` and `fix:` move the version of a generated project; `chore:` and `docs:` do not. +## Versions + +This toolbox is versioned by git tag and nothing else — there is no package to +publish, and the tag is the artefact. `scaffold --version` is `git describe` +against the checkout, and a generated project records that same string in its +`.scaffold.toml`, which is what `scaffold update` later diffs from. + +Cut one from `main` after a change worth telling somebody about: + +```sh +git tag v0.2.0 +git push origin v0.2.0 +``` + +Tagging is deliberately manual. Release Please is not set up here the way it is +in a generated project, because nothing downstream installs this by version: +what a tag buys is a readable answer in `--version` and in every +`.scaffold.toml` written after it, not a distribution channel. + ## Before opening a pull request ```sh diff --git a/README.md b/README.md index 3878c83..b6c0427 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ against the contract. `--version` reports which commit of this toolbox is installed — `git describe`, so a working tree with uncommitted edits says `-dirty`. +`new` prints one line per step rather than a package manager's output, and the +commands to run next when it finishes. `SCAFFOLD_VERBOSE=1` passes everything +through instead; a failing step prints its whole output either way. + `--db` and `--cache` select a database and a cache; each defaults to `none` except `--db`, which defaults to `mysql` for a project with an `--api` or `--app` adapter. Requesting either on a project with neither is refused — diff --git a/lib/adapter.sh b/lib/adapter.sh index 2b0a29d..f8a0cee 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -135,9 +135,10 @@ apply_adapter() { # Through mise exec, not a bare eval: without it, node and pnpm resolve from # whatever is ambient on the caller's PATH instead of the project's own # pin — composer stays ambient too, on purpose (docs/decisions/0016). - ( cd "$parent" && APP_DIR="$(basename "$dest")" \ - npm_config_frozen_lockfile=false \ - mise exec -- bash -c "$ADAPTER_GENERATOR" ) + step "generating ${rel} with ${name} (a framework generator, this takes a few minutes)" + run_quietly "generating ${rel} with ${name}" \ + env APP_DIR="$(basename "$dest")" npm_config_frozen_lockfile=false \ + bash -c "cd \"\$1\" && mise exec -- bash -c \"\$2\"" _ "$parent" "$ADAPTER_GENERATOR" verify_workspace_filter_name "$dest" @@ -173,10 +174,10 @@ apply_adapter() { # sync_workspace_lockfile, the one window where node_modules is meant to # disagree with the lockfile. Left on, `pnpm exec` runs its own install # first and reports only `Command failed with exit code 1` when it fails. - ( cd "$dest" \ - && npm_config_frozen_lockfile=false \ - npm_config_verify_deps_before_run=false \ - mise exec -- bash -c "$ADAPTER_POST_GENERATE" ) + step "configuring ${rel}" + run_quietly "configuring ${rel} after its generator ran" \ + env npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ + bash -c "cd \"\$1\" && mise exec -- bash -c \"\$2\"" _ "$dest" "$ADAPTER_POST_GENERATE" fi # After post-generate: the generator and its own follow-up have settled the diff --git a/lib/log.sh b/lib/log.sh index 3f6efe9..92ce361 100644 --- a/lib/log.sh +++ b/lib/log.sh @@ -2,3 +2,38 @@ log() { printf '%s\n' "$*" >&2; } warn() { printf 'warning: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# step +# A line before a step that takes minutes, so a captured command does not look +# like a hang. Numbered nothing and totalled nothing: the number of steps +# depends on the adapters requested, and a "3 of 7" that is wrong is worse +# than no count. +step() { printf '→ %s\n' "$*" >&2; } + +# run_quietly ... +# Runs a command with its output captured, and prints that output only if it +# fails. `scaffold new` used to hand the terminal several minutes of a package +# manager's progress bars, through which the one line that mattered — which +# application is being generated — never appeared at all. +# +# SCAFFOLD_VERBOSE=1 passes the output straight through. The failure path +# already prints everything, so this is for a run that hangs rather than +# fails, where there is otherwise nothing to look at. +run_quietly() { + local what="$1"; shift + local log status=0 + + if [ "${SCAFFOLD_VERBOSE:-0}" = 1 ]; then + "$@" || die "failed while ${what}" + return 0 + fi + + log="$(mktemp)" + "$@" >"$log" 2>&1 || status=$? + if [ "$status" -ne 0 ]; then + cat "$log" >&2 + rm -f "$log" + die "failed while ${what}" + fi + rm -f "$log" +} diff --git a/lib/project.sh b/lib/project.sh index 44c3d0f..36d8962 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -350,6 +350,7 @@ sync_standalone_build_policy() { # still quiet. pnpm_install() { local dir="$1" what="$2" log status=0 + step "$what" log="$(mktemp)" ( @@ -388,6 +389,7 @@ pnpm_install() { resolve_minimum_release_age() { local project="$1" local settings="${2:-$1}" + step "checking $(basename "$project")'s lockfile against the supply-chain policy" local workspace_file="${settings}/pnpm-workspace.yaml" # Keyed on the lockfile pnpm will actually verify — which for an app outside diff --git a/lib/service.sh b/lib/service.sh index a447817..0ce8afd 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -462,20 +462,25 @@ apply_service_drivers() { pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" node_bin="$(dirname "$(mise which node -C "$app")")" - PATH="${pnpm_bin}:${node_bin}:${PATH}" \ - npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ - SCAFFOLD_PROJECT_ROOT="$project" \ - bash -euo pipefail -c ' + # The child's own script, held in a variable so it reaches `bash -c` + # through `env` intact. Its `$1`/`$2` and ${SCAFFOLD_ROOT} are the + # child's to expand, not this shell's, and the two `.` lines source paths + # that vary per service and family. + # shellcheck disable=SC2016 + local driver_script=' cd "$1" - # shellcheck source=/dev/null . "${SCAFFOLD_ROOT}/lib/log.sh" - # shellcheck source=/dev/null . "${SCAFFOLD_ROOT}/lib/service.sh" - # shellcheck source=/dev/null . "$2" service_driver_apply - ' _ "$app" "$driver" \ - || die "the ${service} driver failed for ${family}" + ' + + step "wiring ${service} into $(app_service_key "$app")" + run_quietly "wiring ${service} into $(app_service_key "$app") (the ${family} driver)" \ + env PATH="${pnpm_bin}:${node_bin}:${PATH}" \ + npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ + SCAFFOLD_PROJECT_ROOT="$project" \ + bash -euo pipefail -c "$driver_script" _ "$app" "$driver" # A driver with nothing to add to the Dockerfile (redis's drivers, on # both families) returns an empty string; appending it anyway spliced a diff --git a/scaffold b/scaffold index f55d178..18ea86e 100755 --- a/scaffold +++ b/scaffold @@ -350,9 +350,18 @@ cmd_new() { register_image_target "$target" "$(role_path "${entry%%:*}")" done + step "locking the toolchain and committing" finalize_project "$target" trap - EXIT + + log "" log "created ${target}" + log "" + log "next:" + log " cd ${target}" + log " mise install && mise exec -- lefthook install" + log " scaffold publish # create its GitHub repository" + log " mise run checklist # what CI will run" } # cmd_add_cleanup — same EXIT-trap diff --git a/tests/new-project.bats b/tests/new-project.bats index a9b5cee..c579084 100644 --- a/tests/new-project.bats +++ b/tests/new-project.bats @@ -47,6 +47,38 @@ teardown() { [ ! -e "${SCAFFOLD_ROOT}/demo-relative" ] } +@test "new reports its steps instead of a package manager's output" { + # It used to hand the terminal several minutes of progress bars, through + # which the one line that mattered — which application is being generated — + # never appeared at all. + run scaffold new "$PROJECT" + assert_ok + [[ "$output" == *"→ "* ]] || { echo "no step lines:"; echo "$output"; false; } + [[ "$output" != *"Progress: resolved"* ]] \ + || { echo "package manager output reached the terminal"; false; } + # And says what to do with what it just made. + [[ "$output" == *"next:"* ]] + [[ "$output" == *"scaffold publish"* ]] +} + +@test "a captured step still shows everything when it fails" { + # The whole point of hiding output is that a failure prints all of it. + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh' + run_quietly 'the probe' bash -c 'echo the-only-clue; exit 3'" + [ "$status" -ne 0 ] + [[ "$output" == *"the-only-clue"* ]] + [[ "$output" == *"failed while the probe"* ]] +} + +@test "SCAFFOLD_VERBOSE passes a step's output straight through" { + # For a run that hangs rather than fails, where there is otherwise nothing + # to look at. + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh' + SCAFFOLD_VERBOSE=1 run_quietly 'the probe' bash -c 'echo live-output'" + assert_ok + [[ "$output" == *"live-output"* ]] +} + @test "new copies the common layer" { scaffold new "$PROJECT" [ -f "${PROJECT}/lefthook.yml" ] From f9637e89f1b353956a6396a5a69f4b41cc5c8991 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Fri, 11 Sep 2026 23:55:22 +0700 Subject: [PATCH 2/5] refactor: give lib/project.sh one subject instead of five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It carried the pnpm workspace, ADR-0017's supply-chain policy, the config_roots manifest, the GitHub account, the project name rule and the git commit — 545 lines whose only relation was being needed by `scaffold new`. lib/pnpm.sh takes the workspace and the policy; lib/manifest.sh takes config_roots, the CI matrix derived from it and the build targets derived from the applications. project.sh keeps what a project is made of and how it is finished, at 264 lines. cmd_new's all-typescript/mixed branch becomes join_typescript_workspace and keep_apps_standalone. It was an if/else long enough that the condition and its consequence never appeared on screen together; the decision is now eleven lines. The pair carries : because the second branch asks the adapter whether it is typescript — 'does it have a package.json' is a different question with a different answer, and laravel-inertia is where the two part company. No behaviour change intended: both branches were exercised end to end against real generations before and after. --- lib/manifest.sh | 108 +++++++++++++ lib/pnpm.sh | 266 +++++++++++++++++++++++++++++++ lib/project.sh | 283 --------------------------------- scaffold | 73 +++------ tests/new-laravel-inertia.bats | 2 +- tests/new-nestjs.bats | 1 + tests/new-project.bats | 6 +- tests/update.bats | 2 + 8 files changed, 401 insertions(+), 340 deletions(-) create mode 100644 lib/manifest.sh create mode 100644 lib/pnpm.sh diff --git a/lib/manifest.sh b/lib/manifest.sh new file mode 100644 index 0000000..319a29a --- /dev/null +++ b/lib/manifest.sh @@ -0,0 +1,108 @@ +# shellcheck shell=bash +# +# What a project publishes and what CI runs over, both derived from one list +# rather than written twice. +# +# `config_roots` in mise.toml is the manifest (ADR-0013): register_config_root +# is the single place a root enters it, and sync_ci_roots copies it into the CI +# workflow so the two cannot disagree. register_image_target does the same job +# for the applications a project builds images from (ADR-0022). + +# register_config_root +register_config_root() { + local project="$1" root="$2" + local file="${project}/mise.toml" + + # Both halves below are anchored on the exact formatting mise.root.toml + # ships, and both used to no-op silently when it did not match — an inline + # `config_roots = ["docs"]` left the roots half untouched while the checklist + # half succeeded, and the project shipped a CI matrix of [] that passed green + # while running nothing. Verified rather than assumed, on each half. + if ! grep -q "^ \"${root}\",\$" "$file"; then + awk -v root="$root" ' + { print } + /^config_roots = \[$/ { printf " \"%s\",\n", root } + ' "$file" > "${file}.tmp" + mv "${file}.tmp" "$file" + grep -q "^ \"${root}\",\$" "$file" \ + || die "could not register ${root}: no 'config_roots = [' line in ${file} — has it been reformatted?" + fi + + # the root [tasks.checklist] (pre-push's own gate) must run every config + # root's own checklist, not just docs' — register_config_root is the one + # place every config root passes through, so this stays in lockstep with + # config_roots itself instead of being a second list a later task forgets + # to update. + if ! grep -q "\"//${root}:checklist\"" "$file"; then + awk -v root="$root" ' + /^\[tasks\.checklist\]$/ { in_checklist = 1 } + in_checklist && /^run = \[/ { + sub(/\]$/, ", { task = \"//" root ":checklist\" }]") + in_checklist = 0 + } + { print } + ' "$file" > "${file}.tmp" + mv "${file}.tmp" "$file" + grep -q "\"//${root}:checklist\"" "$file" \ + || die "could not add ${root} to the root checklist in ${file} — has [tasks.checklist] been reformatted?" + fi +} +# collect_config_roots +collect_config_roots() { + sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ + | sed -n 's/^ "\(.*\)",$/\1/p' +} +# sync_ci_roots — the ci workflow's matrix input is derived from the +# manifest so the two can never disagree. +sync_ci_roots() { + local project="$1" json + json="$(collect_config_roots "$project" | jq -R . | jq -sc .)" + sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ + "${project}/.github/workflows/ci.yml" + rm -f "${project}/.github/workflows/ci.yml.bak" +} +# register_image_target — add one entry to the `images` array +# build.yml and release.yml pass to the reusable workflow (ADR-0022). +# +# This replaced a pair of functions that wrote one context/dockerfile pair per +# project: every applied adapter overwrote the previous one, so a project with +# a web and an api application published only whichever was applied last, and +# the other passed CI and was never built at all. +# +# Called after the workspace decision is settled, not during it: an +# application's build context depends on whether it resolves through the +# shared pnpm workspace or owns its manifests, which cmd_new decides only once +# every adapter has been applied. +register_image_target() { + local project="$1" rel="$2" + local name context dockerfile image file current updated + + name="$(app_service_key "$rel")" + image="$(project_image_base "$project")-${name}" + dockerfile="${rel}/Dockerfile" + + # A workspace member has no package.json or lockfile of its own — they live + # at the root — so its Dockerfile's first COPY only resolves from there. + if app_is_workspace_member "$project" "$rel"; then + context="." + else + context="$rel" + fi + + [ -f "${project}/${dockerfile}" ] \ + || die "no Dockerfile at ${dockerfile} to build ${name} from" + + for file in "${project}/.github/workflows/build.yml" \ + "${project}/.github/workflows/release.yml"; do + current="$(yq -r '[.jobs[] | select(has("with")) | .with.images] | .[0] // "[]"' "$file")" + updated="$(jq -c --arg image "$image" --arg context "$context" \ + --arg dockerfile "$dockerfile" \ + '. + [{image: $image, context: $context, dockerfile: $dockerfile}]' \ + <<<"$current")" \ + || die "could not read the images array out of ${file}" + + IMAGES="$updated" yq --inplace \ + '(.jobs[] | select(has("with")) | .with.images) = strenv(IMAGES)' "$file" \ + || die "could not record ${name}'s image in ${file}" + done +} diff --git a/lib/pnpm.sh b/lib/pnpm.sh new file mode 100644 index 0000000..42abd49 --- /dev/null +++ b/lib/pnpm.sh @@ -0,0 +1,266 @@ +# shellcheck shell=bash +# +# The pnpm workspace, and the supply-chain policy that governs what may be +# installed into it (ADR-0017). +# +# Split out of lib/project.sh, which had grown to carry this, the config_roots +# manifest, the GitHub account, the project name rule and the git commit — five +# subjects whose only relation was being needed by `scaffold new`. + +# enable_typescript_workspace +# only called when every application in the project is typescript; sharing +# types across a language boundary is a different problem, solved by openapi. +enable_typescript_workspace() { + local project="$1" + + mkdir -p "${project}/packages" + mv "${project}/packages-types" "${project}/packages/types" + register_config_root "$project" "packages/types" +} +# Not every generator notices the pnpm-workspace.yaml init_project already +# wrote. create-next-app writes its own apps/web/pnpm-lock.yaml and +# pnpm-workspace.yaml, and pnpm's upward search finds the nested one first — +# so the app never resolves as part of the outer workspace, which is fatal for +# a multi-app project and harmless for a standalone one. Drop the strays and +# rebuild one root lockfile. The minimum-release-age relaxation covers this +# pass only; resolve_minimum_release_age still enforces the real default. +sync_workspace_lockfile() { + local project="$1" + + find "$project" -mindepth 3 -maxdepth 3 \ + \( -name pnpm-lock.yaml -o -name pnpm-workspace.yaml \) -delete + + pnpm_install "$project" "reconciling the workspace lockfile" +} +# sync_standalone_build_policy +# An app that stands alone rather than joining the workspace (mixed-language) +# resolves its own generator's pnpm-workspace.yaml, if any — the root one, +# carrying ADR-0017's allowBuilds, is never reached: pnpm's upward search +# stops at the first workspace file it finds, and so does the docker build +# context (apps/ only, never the root). Without this, common's baseline +# (unrs-resolver, esbuild, @parcel/watcher) is simply absent for this app, and +# any of it this app needs fails ERR_PNPM_IGNORED_BUILDS the moment nothing +# outside apps/ is there to answer for it. Merge: common wins on a key +# both name, the app's own generator (e.g. create-next-app denying sharp) +# keeps any key only it names. +sync_standalone_build_policy() { + local app="$1" project="$2" + local file="${app}/pnpm-workspace.yaml" + + [ -f "$file" ] || printf '{}\n' > "$file" + + yq eval-all --inplace \ + 'select(fileIndex==0).allowBuilds = ((select(fileIndex==0).allowBuilds // {}) * select(fileIndex==1).allowBuilds) | select(fileIndex==0)' \ + "$file" "${project}/pnpm-workspace.yaml" +} +# pnpm_install +# pnpm reports its failures on stdout, so silencing the install leaves a `die` +# that names the step and proves nothing — a CI failure here was unreadable +# until this kept the output. Shown only on failure; a successful install is +# still quiet. +pnpm_install() { + local dir="$1" what="$2" log status=0 + step "$what" + log="$(mktemp)" + + ( + cd "$dir" + # --no-frozen-lockfile because pnpm turns frozen on by itself when CI=true, + # and this install exists precisely to rewrite the lockfile a generator just + # produced. Without it the step is a contradiction that only fails on a + # runner: reconcile the lockfile, but you may not change the lockfile. + mise exec -- pnpm install \ + --no-frozen-lockfile \ + --config.confirm-modules-purge=false \ + --config.minimum-release-age=0 + ) >"$log" 2>&1 || status=$? + + if [ "$status" -ne 0 ]; then + cat "$log" >&2 + rm -f "$log" + die "pnpm install failed while ${what}" + fi + rm -f "$log" +} +# pnpm re-checks minimum-release-age on every frozen install, not just the +# first, so relaxing it for one call would not hold. Record the too-fresh +# entries in the project's own file instead, leaving the policy live for +# everything it adds later. Excluding one batch can reveal another, so this +# loops — capped, so a different failure cannot spin forever. +# resolve_minimum_release_age [settings-dir] +# Runs the frozen install from and records the exclusions in +# 's pnpm-workspace.yaml, defaulting to the same place. +# +# The two differ for an app outside a workspace: its contract tasks install +# from the app, which is the only place pnpm resolves its dependencies — the +# project root holds the lockfile but its own package.json names none of them, +# so running there found no violation and the app still could not install. +resolve_minimum_release_age() { + local project="$1" + local settings="${2:-$1}" + step "checking $(basename "$project")'s lockfile against the supply-chain policy" + local workspace_file="${settings}/pnpm-workspace.yaml" + + # Keyed on the lockfile pnpm will actually verify — which for an app outside + # a workspace is the root's, found by walking up. + [ -f "${project}/pnpm-lock.yaml" ] || [ -f "${settings}/pnpm-lock.yaml" ] || return 0 + + local max_rounds=10 round=0 log entries all_entries="" + log="$(mktemp)" + + while true; do + if ( cd "$project" && mise exec -- pnpm install --frozen-lockfile --config.confirm-modules-purge=false >"$log" 2>&1 ); then + rm -f "$log" + return 0 + fi + + grep -q ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION "$log" || { + cat "$log" >&2 + rm -f "$log" + die "pnpm install failed for a reason other than minimum-release-age (see above)" + } + + round=$((round + 1)) + [ "$round" -le "$max_rounds" ] || { + cat "$log" >&2 + rm -f "$log" + die "pnpm install still hits new minimum-release-age violations after ${max_rounds} rounds of recording exceptions" + } + + entries="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -n 's/^ \(.*\) was published.*/\1/p')" + [ -n "$entries" ] || { + cat "$log" >&2 + rm -f "$log" + die "pnpm reported a minimum-release-age failure but no entries could be parsed from it (see above)" + } + + all_entries="$(printf '%s\n%s\n' "$all_entries" "$entries" | sed '/^$/d' | sort -u)" + + # bounded by an explicit start AND end marker, not a delete-to-eof: a + # range open on the end (,$d) would silently swallow anything appended + # after this block by a later step or caller, with nothing printed. + # both markers are always written together below, so the range is + # always well-formed by the time this runs a second time. + [ -f "$workspace_file" ] || : > "$workspace_file" + sed -i '/^# too fresh at generation time/,/^# end minimumReleaseAgeExclude$/d' "$workspace_file" + { + printf '# too fresh at generation time; pnpm re-checks this on every frozen\n' + printf '# install forever, not just this one, so it is recorded once here\n' + printf '# instead of turned off for every dependency this project adds later.\n' + printf 'minimumReleaseAgeExclude:\n' + printf '%s\n' "$all_entries" | while IFS= read -r entry; do printf ' - "%s"\n' "$entry"; done + printf '# end minimumReleaseAgeExclude\n' + } >> "$workspace_file" + done +} +# app_is_workspace_member — true when rel falls inside +# pnpm-workspace.yaml's packages: globs, i.e. rel resolves through the shared +# root install rather than owning a package.json/lockfile of its own. This is +# what actually decides which Dockerfile variant an app needs (finalize_app_ +# dockerfile) and what its build context has to be — not whether the command +# generating it was `new` or `add`, and not whether every adapter requested +# at `new` time happened to be typescript (cmd_new's all_typescript is just +# how that project arrived at this same state). +app_is_workspace_member() { + local project="$1" rel="$2" + local workspace_file="${project}/pnpm-workspace.yaml" glob + + [ -f "$workspace_file" ] || return 1 + + while IFS= read -r glob; do + [ -n "$glob" ] || continue + # shellcheck disable=SC2254 # glob is a pattern by design, not a literal + case "$rel" in $glob) return 0 ;; esac + done < <(yq -r '.packages[]? // ""' "$workspace_file") + + return 1 +} +# finalize_app_dockerfile — apply_adapter's flat copy lands +# both Dockerfile and Dockerfile.workspace for any adapter that ships one +# (nestjs, nextjs); exactly one may survive, whichever matches +# app_is_workspace_member, since that's what the standalone Dockerfile's +# assumption of its own lockfile actually depends on. +finalize_app_dockerfile() { + local project="$1" rel="$2" + local dir="${project}/${rel}" + + [ -f "${dir}/Dockerfile.workspace" ] || return 0 + + if app_is_workspace_member "$project" "$rel"; then + mv -f "${dir}/Dockerfile.workspace" "${dir}/Dockerfile" + else + rm -f "${dir}/Dockerfile.workspace" + fi +} + +# join_typescript_workspace :... +# Every application is TypeScript, so they share one lockfile and one +# node_modules at the project root, and a packages/types can exist between +# them. Lifted out of cmd_new, where it was one arm of an if/else long enough +# that the condition and the consequence never appeared on screen together. +join_typescript_workspace() { + local project="$1"; shift + + enable_typescript_workspace "$project" + + # docs ships its own standalone pnpm-workspace.yaml/pnpm-lock.yaml for a + # project with no typescript adapter; here it joins the real workspace + # instead, so its own copies would only sit there unused at best, and shadow + # the root pnpm-workspace.yaml for docs' own tasks at worst. + rm -f "${project}/docs/pnpm-workspace.yaml" "${project}/docs/pnpm-lock.yaml" + + sync_workspace_lockfile "$project" + resolve_minimum_release_age "$project" + + # Every application just lost its own package.json and lockfile to the + # workspace above, and apply_adapter's standalone Dockerfile assumed it had + # one. finalize_app_dockerfile swaps in the workspace-flavored Dockerfile + # each typescript adapter ships beside it. + local pair + for pair in "$@"; do + finalize_app_dockerfile "$project" "${pair%%:*}" + done +} + +# keep_apps_standalone :... +# Not every application is TypeScript — or there are none — so each owns its +# manifests and its own lockfile, and there is no shared workspace to join. +keep_apps_standalone() { + local project="$1"; shift + + rm -rf "${project}/packages-types" + + # The packages list goes, but the file stays either way: it carries + # ADR-0017's allowBuilds, which applies to any node install here, including + # the root package.json a php-only project still needs for commitlint. + # Deleting it left that project with no recorded build-script decision. + yq --inplace 'del(.packages)' "${project}/pnpm-workspace.yaml" + + # The root package.json still needs installing on its own — commitlint backs + # lefthook's commit-msg hook, which must work in a php-only project + # (docs/decisions/0007). + pnpm_install "$project" "installing the project root's own tooling dependencies" + # That install ran at full strength (minimum-release-age=0), so a violation + # among commitlint's own dependencies never surfaced — the loop below + # resolves each application's lockfile but never the root's, and the root's + # is the one the commit-msg hook installs from. + resolve_minimum_release_age "$project" + + # The adapter travels with the path because this branch asks about it: only + # a typescript application resolves through a pnpm workspace file, and + # "does it have a package.json" is a different question with a different + # answer (laravel-inertia has one, for vite, and is not typescript). + local pair app + for pair in "$@"; do + app="${pair%%:*}" + # Each application here stands alone, so each owns a lockfile the policy + # will re-check forever. + adapter_is_typescript "${pair#*:}" \ + && sync_standalone_build_policy "${project}/${app}" "$project" + # The standalone Dockerfile is the one this shape builds from; a + # typescript adapter's workspace-flavored sibling never applies here and + # would otherwise ship unused. + finalize_app_dockerfile "$project" "$app" + resolve_minimum_release_age "${project}/${app}" + done +} diff --git a/lib/project.sh b/lib/project.sh index 36d8962..d8309b3 100644 --- a/lib/project.sh +++ b/lib/project.sh @@ -207,299 +207,16 @@ init_project() { mise trust -y --quiet -C "$dir" } -# register_image_target — add one entry to the `images` array -# build.yml and release.yml pass to the reusable workflow (ADR-0022). -# -# This replaced a pair of functions that wrote one context/dockerfile pair per -# project: every applied adapter overwrote the previous one, so a project with -# a web and an api application published only whichever was applied last, and -# the other passed CI and was never built at all. -# -# Called after the workspace decision is settled, not during it: an -# application's build context depends on whether it resolves through the -# shared pnpm workspace or owns its manifests, which cmd_new decides only once -# every adapter has been applied. -register_image_target() { - local project="$1" rel="$2" - local name context dockerfile image file current updated - - name="$(app_service_key "$rel")" - image="$(project_image_base "$project")-${name}" - dockerfile="${rel}/Dockerfile" - - # A workspace member has no package.json or lockfile of its own — they live - # at the root — so its Dockerfile's first COPY only resolves from there. - if app_is_workspace_member "$project" "$rel"; then - context="." - else - context="$rel" - fi - [ -f "${project}/${dockerfile}" ] \ - || die "no Dockerfile at ${dockerfile} to build ${name} from" - - for file in "${project}/.github/workflows/build.yml" \ - "${project}/.github/workflows/release.yml"; do - current="$(yq -r '[.jobs[] | select(has("with")) | .with.images] | .[0] // "[]"' "$file")" - updated="$(jq -c --arg image "$image" --arg context "$context" \ - --arg dockerfile "$dockerfile" \ - '. + [{image: $image, context: $context, dockerfile: $dockerfile}]' \ - <<<"$current")" \ - || die "could not read the images array out of ${file}" - - IMAGES="$updated" yq --inplace \ - '(.jobs[] | select(has("with")) | .with.images) = strenv(IMAGES)' "$file" \ - || die "could not record ${name}'s image in ${file}" - done -} -# app_is_workspace_member — true when rel falls inside -# pnpm-workspace.yaml's packages: globs, i.e. rel resolves through the shared -# root install rather than owning a package.json/lockfile of its own. This is -# what actually decides which Dockerfile variant an app needs (finalize_app_ -# dockerfile) and what its build context has to be — not whether the command -# generating it was `new` or `add`, and not whether every adapter requested -# at `new` time happened to be typescript (cmd_new's all_typescript is just -# how that project arrived at this same state). -app_is_workspace_member() { - local project="$1" rel="$2" - local workspace_file="${project}/pnpm-workspace.yaml" glob - - [ -f "$workspace_file" ] || return 1 - - while IFS= read -r glob; do - [ -n "$glob" ] || continue - # shellcheck disable=SC2254 # glob is a pattern by design, not a literal - case "$rel" in $glob) return 0 ;; esac - done < <(yq -r '.packages[]? // ""' "$workspace_file") - - return 1 -} -# finalize_app_dockerfile — apply_adapter's flat copy lands -# both Dockerfile and Dockerfile.workspace for any adapter that ships one -# (nestjs, nextjs); exactly one may survive, whichever matches -# app_is_workspace_member, since that's what the standalone Dockerfile's -# assumption of its own lockfile actually depends on. -finalize_app_dockerfile() { - local project="$1" rel="$2" - local dir="${project}/${rel}" - - [ -f "${dir}/Dockerfile.workspace" ] || return 0 - - if app_is_workspace_member "$project" "$rel"; then - mv -f "${dir}/Dockerfile.workspace" "${dir}/Dockerfile" - else - rm -f "${dir}/Dockerfile.workspace" - fi -} -# enable_typescript_workspace -# only called when every application in the project is typescript; sharing -# types across a language boundary is a different problem, solved by openapi. -enable_typescript_workspace() { - local project="$1" - mkdir -p "${project}/packages" - mv "${project}/packages-types" "${project}/packages/types" - register_config_root "$project" "packages/types" -} -# Not every generator notices the pnpm-workspace.yaml init_project already -# wrote. create-next-app writes its own apps/web/pnpm-lock.yaml and -# pnpm-workspace.yaml, and pnpm's upward search finds the nested one first — -# so the app never resolves as part of the outer workspace, which is fatal for -# a multi-app project and harmless for a standalone one. Drop the strays and -# rebuild one root lockfile. The minimum-release-age relaxation covers this -# pass only; resolve_minimum_release_age still enforces the real default. -sync_workspace_lockfile() { - local project="$1" - find "$project" -mindepth 3 -maxdepth 3 \ - \( -name pnpm-lock.yaml -o -name pnpm-workspace.yaml \) -delete - pnpm_install "$project" "reconciling the workspace lockfile" -} -# sync_standalone_build_policy -# An app that stands alone rather than joining the workspace (mixed-language) -# resolves its own generator's pnpm-workspace.yaml, if any — the root one, -# carrying ADR-0017's allowBuilds, is never reached: pnpm's upward search -# stops at the first workspace file it finds, and so does the docker build -# context (apps/ only, never the root). Without this, common's baseline -# (unrs-resolver, esbuild, @parcel/watcher) is simply absent for this app, and -# any of it this app needs fails ERR_PNPM_IGNORED_BUILDS the moment nothing -# outside apps/ is there to answer for it. Merge: common wins on a key -# both name, the app's own generator (e.g. create-next-app denying sharp) -# keeps any key only it names. -sync_standalone_build_policy() { - local app="$1" project="$2" - local file="${app}/pnpm-workspace.yaml" - - [ -f "$file" ] || printf '{}\n' > "$file" - - yq eval-all --inplace \ - 'select(fileIndex==0).allowBuilds = ((select(fileIndex==0).allowBuilds // {}) * select(fileIndex==1).allowBuilds) | select(fileIndex==0)' \ - "$file" "${project}/pnpm-workspace.yaml" -} -# pnpm_install -# pnpm reports its failures on stdout, so silencing the install leaves a `die` -# that names the step and proves nothing — a CI failure here was unreadable -# until this kept the output. Shown only on failure; a successful install is -# still quiet. -pnpm_install() { - local dir="$1" what="$2" log status=0 - step "$what" - log="$(mktemp)" - - ( - cd "$dir" - # --no-frozen-lockfile because pnpm turns frozen on by itself when CI=true, - # and this install exists precisely to rewrite the lockfile a generator just - # produced. Without it the step is a contradiction that only fails on a - # runner: reconcile the lockfile, but you may not change the lockfile. - mise exec -- pnpm install \ - --no-frozen-lockfile \ - --config.confirm-modules-purge=false \ - --config.minimum-release-age=0 - ) >"$log" 2>&1 || status=$? - - if [ "$status" -ne 0 ]; then - cat "$log" >&2 - rm -f "$log" - die "pnpm install failed while ${what}" - fi - rm -f "$log" -} - -# pnpm re-checks minimum-release-age on every frozen install, not just the -# first, so relaxing it for one call would not hold. Record the too-fresh -# entries in the project's own file instead, leaving the policy live for -# everything it adds later. Excluding one batch can reveal another, so this -# loops — capped, so a different failure cannot spin forever. -# resolve_minimum_release_age [settings-dir] -# Runs the frozen install from and records the exclusions in -# 's pnpm-workspace.yaml, defaulting to the same place. -# -# The two differ for an app outside a workspace: its contract tasks install -# from the app, which is the only place pnpm resolves its dependencies — the -# project root holds the lockfile but its own package.json names none of them, -# so running there found no violation and the app still could not install. -resolve_minimum_release_age() { - local project="$1" - local settings="${2:-$1}" - step "checking $(basename "$project")'s lockfile against the supply-chain policy" - local workspace_file="${settings}/pnpm-workspace.yaml" - - # Keyed on the lockfile pnpm will actually verify — which for an app outside - # a workspace is the root's, found by walking up. - [ -f "${project}/pnpm-lock.yaml" ] || [ -f "${settings}/pnpm-lock.yaml" ] || return 0 - - local max_rounds=10 round=0 log entries all_entries="" - log="$(mktemp)" - - while true; do - if ( cd "$project" && mise exec -- pnpm install --frozen-lockfile --config.confirm-modules-purge=false >"$log" 2>&1 ); then - rm -f "$log" - return 0 - fi - - grep -q ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION "$log" || { - cat "$log" >&2 - rm -f "$log" - die "pnpm install failed for a reason other than minimum-release-age (see above)" - } - - round=$((round + 1)) - [ "$round" -le "$max_rounds" ] || { - cat "$log" >&2 - rm -f "$log" - die "pnpm install still hits new minimum-release-age violations after ${max_rounds} rounds of recording exceptions" - } - - entries="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -n 's/^ \(.*\) was published.*/\1/p')" - [ -n "$entries" ] || { - cat "$log" >&2 - rm -f "$log" - die "pnpm reported a minimum-release-age failure but no entries could be parsed from it (see above)" - } - - all_entries="$(printf '%s\n%s\n' "$all_entries" "$entries" | sed '/^$/d' | sort -u)" - - # bounded by an explicit start AND end marker, not a delete-to-eof: a - # range open on the end (,$d) would silently swallow anything appended - # after this block by a later step or caller, with nothing printed. - # both markers are always written together below, so the range is - # always well-formed by the time this runs a second time. - [ -f "$workspace_file" ] || : > "$workspace_file" - sed -i '/^# too fresh at generation time/,/^# end minimumReleaseAgeExclude$/d' "$workspace_file" - { - printf '# too fresh at generation time; pnpm re-checks this on every frozen\n' - printf '# install forever, not just this one, so it is recorded once here\n' - printf '# instead of turned off for every dependency this project adds later.\n' - printf 'minimumReleaseAgeExclude:\n' - printf '%s\n' "$all_entries" | while IFS= read -r entry; do printf ' - "%s"\n' "$entry"; done - printf '# end minimumReleaseAgeExclude\n' - } >> "$workspace_file" - done -} - -# register_config_root -register_config_root() { - local project="$1" root="$2" - local file="${project}/mise.toml" - - # Both halves below are anchored on the exact formatting mise.root.toml - # ships, and both used to no-op silently when it did not match — an inline - # `config_roots = ["docs"]` left the roots half untouched while the checklist - # half succeeded, and the project shipped a CI matrix of [] that passed green - # while running nothing. Verified rather than assumed, on each half. - if ! grep -q "^ \"${root}\",\$" "$file"; then - awk -v root="$root" ' - { print } - /^config_roots = \[$/ { printf " \"%s\",\n", root } - ' "$file" > "${file}.tmp" - mv "${file}.tmp" "$file" - grep -q "^ \"${root}\",\$" "$file" \ - || die "could not register ${root}: no 'config_roots = [' line in ${file} — has it been reformatted?" - fi - - # the root [tasks.checklist] (pre-push's own gate) must run every config - # root's own checklist, not just docs' — register_config_root is the one - # place every config root passes through, so this stays in lockstep with - # config_roots itself instead of being a second list a later task forgets - # to update. - if ! grep -q "\"//${root}:checklist\"" "$file"; then - awk -v root="$root" ' - /^\[tasks\.checklist\]$/ { in_checklist = 1 } - in_checklist && /^run = \[/ { - sub(/\]$/, ", { task = \"//" root ":checklist\" }]") - in_checklist = 0 - } - { print } - ' "$file" > "${file}.tmp" - mv "${file}.tmp" "$file" - grep -q "\"//${root}:checklist\"" "$file" \ - || die "could not add ${root} to the root checklist in ${file} — has [tasks.checklist] been reformatted?" - fi -} - -# collect_config_roots -collect_config_roots() { - sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \ - | sed -n 's/^ "\(.*\)",$/\1/p' -} - -# sync_ci_roots — the ci workflow's matrix input is derived from the -# manifest so the two can never disagree. -sync_ci_roots() { - local project="$1" json - json="$(collect_config_roots "$project" | jq -R . | jq -sc .)" - sed -i.bak "s|^ roots: .*| roots: '${json}'|" \ - "${project}/.github/workflows/ci.yml" - rm -f "${project}/.github/workflows/ci.yml.bak" -} # lock_toolchains # `mise install` writes a lockfile naming versions but no download URLs when the diff --git a/scaffold b/scaffold index 18ea86e..5793c61 100755 --- a/scaffold +++ b/scaffold @@ -22,6 +22,10 @@ source "${SCAFFOLD_ROOT}/lib/wizard.sh" source "${SCAFFOLD_ROOT}/lib/tui.sh" # shellcheck source=lib/project.sh source "${SCAFFOLD_ROOT}/lib/project.sh" +# shellcheck source=lib/pnpm.sh +source "${SCAFFOLD_ROOT}/lib/pnpm.sh" +# shellcheck source=lib/manifest.sh +source "${SCAFFOLD_ROOT}/lib/manifest.sh" # shellcheck source=lib/update.sh source "${SCAFFOLD_ROOT}/lib/update.sh" # shellcheck source=lib/publish.sh @@ -278,68 +282,27 @@ cmd_new() { done fi - # This decides one thing only: whether a shared packages/types is possible, - # which needs every app to be TypeScript. It used to decide whether the root - # pnpm-workspace.yaml survived as well — so one PHP app deleted the file - # carrying ADR-0017's allowBuilds, and with it the policy protecting the - # TypeScript app beside it. The file now stays either way. + # One question, not two: a shared packages/types needs every application to + # be TypeScript. It used to decide whether the root pnpm-workspace.yaml + # survived as well — so one PHP app deleted the file carrying ADR-0017's + # allowBuilds, and with it the policy protecting the TypeScript app beside + # it. The file now stays either way. local all_typescript=1 for entry in "${requested[@]}"; do adapter_is_typescript "${entry#*:}" || all_typescript=0 done + # :, the shape both branches below want: one needs the path, + # the other needs to ask the adapter whether it is typescript. + local -a apps=() + for entry in ${requested[@]+"${requested[@]}"}; do + apps+=("$(role_path "${entry%%:*}"):${entry#*:}") + done + if [ ${#requested[@]} -gt 0 ] && [ "$all_typescript" -eq 1 ]; then - enable_typescript_workspace "$target" - # docs ships its own standalone pnpm-workspace.yaml/pnpm-lock.yaml for a - # project with no typescript adapter; here it joins the real workspace - # instead, so its own copies would only sit there unused at best, and - # shadow the root pnpm-workspace.yaml for docs' own tasks at worst. - rm -f "${target}/docs/pnpm-workspace.yaml" "${target}/docs/pnpm-lock.yaml" - sync_workspace_lockfile "$target" - resolve_minimum_release_age "$target" - - # Every requested role just lost its own package.json/lockfile to the - # workspace above — apply_adapter's standalone Dockerfile assumed it had - # one. finalize_app_dockerfile swaps in the workspace-flavored Dockerfile - # each typescript adapter ships beside it; point the image build at the - # workspace root too, the only place those apps' manifests are reachable - # from now. - for entry in "${requested[@]}"; do - finalize_app_dockerfile "$target" "$(role_path "${entry%%:*}")" - done + join_typescript_workspace "$target" "${apps[@]}" else - rm -rf "${target}/packages-types" - - # No shared workspace without a fully TypeScript project, so the packages - # list goes — but the file stays either way. It carries ADR-0017's - # allowBuilds, which applies to any node install here, including the root - # package.json a php-only project still needs for commitlint. Deleting it - # left that project with no recorded build-script decision at all. - yq --inplace 'del(.packages)' "${target}/pnpm-workspace.yaml" - # The root package.json still needs installing on its own — commitlint - # backs lefthook's commit-msg hook, which must work in a php-only - # project (docs/decisions/0007). - # - pnpm_install "$target" "installing the project root's own tooling dependencies" - # pnpm_install above installed at full strength (minimum-release-age=0), - # so a violation among commitlint's own dependencies never surfaced — - # the loop below resolves each app's lockfile but never the root's, and - # the root's is the one the commit-msg hook installs from. - resolve_minimum_release_age "$target" - - # Each app here stands alone rather than joining a workspace, so each owns - # a lockfile the policy will re-check forever. Same reason as cmd_add's - # else branch. - local generated - for generated in "${requested[@]}"; do - adapter_is_typescript "${generated#*:}" \ - && sync_standalone_build_policy "${target}/$(role_path "${generated%%:*}")" "$target" - # the standalone Dockerfile above is the one this shape builds from; - # a typescript adapter's workspace-flavored sibling never applies here - # and would otherwise ship unused. - finalize_app_dockerfile "$target" "$(role_path "${generated%%:*}")" - resolve_minimum_release_age "${target}/$(role_path "${generated%%:*}")" - done + keep_apps_standalone "$target" ${apps[@]+"${apps[@]}"} fi # Last, because a build context depends on the workspace decision both diff --git a/tests/new-laravel-inertia.bats b/tests/new-laravel-inertia.bats index ba59732..9ed1507 100644 --- a/tests/new-laravel-inertia.bats +++ b/tests/new-laravel-inertia.bats @@ -20,7 +20,7 @@ teardown() { @test "the fullstack project has exactly two config roots" { scaffold new "$PROJECT" --app laravel-inertia - run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/project.sh'; collect_config_roots '${PROJECT}' | sort | tr '\n' ' '" + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/manifest.sh'; collect_config_roots '${PROJECT}' | sort | tr '\n' ' '" [ "$output" = "apps/app docs " ] } diff --git a/tests/new-nestjs.bats b/tests/new-nestjs.bats index 76ce7df..3722987 100644 --- a/tests/new-nestjs.bats +++ b/tests/new-nestjs.bats @@ -31,6 +31,7 @@ teardown() { @test "config roots land in the order the calls actually produce" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" --api nestjs --web nextjs run collect_config_roots "$PROJECT" diff --git a/tests/new-project.bats b/tests/new-project.bats index c579084..056e46a 100644 --- a/tests/new-project.bats +++ b/tests/new-project.bats @@ -105,6 +105,7 @@ teardown() { @test "register_config_root is idempotent" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" register_config_root "$PROJECT" "apps/api" @@ -115,6 +116,7 @@ teardown() { @test "sync_ci_roots writes the roots as a JSON array" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" sync_ci_roots "$PROJECT" @@ -135,6 +137,7 @@ teardown() { @test "register, collect and sync agree on two roots" { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" scaffold new "$PROJECT" register_config_root "$PROJECT" "apps/api" register_config_root "$PROJECT" "apps/web" @@ -206,6 +209,7 @@ teardown() { collect_roots() { source "${SCAFFOLD_ROOT}/lib/log.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" collect_config_roots "$1" } @@ -267,7 +271,7 @@ collect_roots() { printf 'monorepo_root = true\n\n[monorepo]\nconfig_roots = ["docs"]\n\n[tasks.checklist]\nrun = [{ task = "//docs:checklist" }]\n' \ > "${p}/mise.toml" - run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/project.sh'; register_config_root '$p' apps/web" + run bash -c "source '${SCAFFOLD_ROOT}/lib/log.sh'; source '${SCAFFOLD_ROOT}/lib/manifest.sh'; register_config_root '$p' apps/web" [ "$status" -ne 0 ] [[ "$output" == *"config_roots"* ]] } diff --git a/tests/update.bats b/tests/update.bats index 75901b5..9b555c0 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -7,6 +7,8 @@ setup() { source "${SCAFFOLD_ROOT}/lib/adapter.sh" source "${SCAFFOLD_ROOT}/lib/service.sh" source "${SCAFFOLD_ROOT}/lib/project.sh" + source "${SCAFFOLD_ROOT}/lib/pnpm.sh" + source "${SCAFFOLD_ROOT}/lib/manifest.sh" source "${SCAFFOLD_ROOT}/lib/update.sh" WORKDIR="$(mktemp -d)" } From cc7cc01bc2a343346b4851427b9ec2d20945dc89 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 00:06:54 +0700 Subject: [PATCH 3/5] test: assert what the generator guarantee is, not how it is spelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capturing the generators' output moved the invocation, and three tests were reading the old text rather than the guarantee behind it. cli.bats now asserts the negative as well: no value may be handed to a shell directly, and one script runs both through mise exec. That survives the shape being rearranged, which it has now been once. service.bats asserts the failure names the service and the family — the actual requirement, since a bare 'a driver failed' sends the reader to the wrong one of eight — instead of one sentence that run_quietly rephrased. --- lib/adapter.sh | 9 +++++++-- tests/cli.bats | 14 +++++++++----- tests/service.bats | 14 ++++++++++++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/adapter.sh b/lib/adapter.sh index f8a0cee..f80089f 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -135,10 +135,15 @@ apply_adapter() { # Through mise exec, not a bare eval: without it, node and pnpm resolve from # whatever is ambient on the caller's PATH instead of the project's own # pin — composer stays ambient too, on purpose (docs/decisions/0016). + # The child's own script, held in a variable so it survives the trip through + # `env` intact. Its $1 and $2 are the child's to expand. + # shellcheck disable=SC2016 + local in_the_app_toolchain='cd "$1" && mise exec -- bash -c "$2"' + step "generating ${rel} with ${name} (a framework generator, this takes a few minutes)" run_quietly "generating ${rel} with ${name}" \ env APP_DIR="$(basename "$dest")" npm_config_frozen_lockfile=false \ - bash -c "cd \"\$1\" && mise exec -- bash -c \"\$2\"" _ "$parent" "$ADAPTER_GENERATOR" + bash -c "$in_the_app_toolchain" _ "$parent" "$ADAPTER_GENERATOR" verify_workspace_filter_name "$dest" @@ -177,7 +182,7 @@ apply_adapter() { step "configuring ${rel}" run_quietly "configuring ${rel} after its generator ran" \ env npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ - bash -c "cd \"\$1\" && mise exec -- bash -c \"\$2\"" _ "$dest" "$ADAPTER_POST_GENERATE" + bash -c "$in_the_app_toolchain" _ "$dest" "$ADAPTER_POST_GENERATE" fi # After post-generate: the generator and its own follow-up have settled the diff --git a/tests/cli.bats b/tests/cli.bats index 1701054..21bd33c 100755 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -85,11 +85,15 @@ setup() { # ambient pnpm that disagrees with the project's pin; asserting the # invocation shape here costs nothing. Same reasoning as service.bats' "the # nest driver decides allowBuilds before it installs anything". - run grep -c 'mise exec -- bash -c "\$ADAPTER_GENERATOR"' "${SCAFFOLD_ROOT}/lib/adapter.sh" - assert_ok - [ "$output" -eq 1 ] - - run grep -c 'mise exec -- bash -c "\$ADAPTER_POST_GENERATE"' "${SCAFFOLD_ROOT}/lib/adapter.sh" + # Asserted as a negative and a positive, so the shape can be rearranged — + # it has been once, when the output started being captured — without the + # guarantee quietly going with it. Neither value may be handed to a shell + # directly, and the only script that runs either goes through mise exec. + run grep -nE '(eval|bash -c) "\$ADAPTER_(GENERATOR|POST_GENERATE)"' "${SCAFFOLD_ROOT}/lib/adapter.sh" + [ -z "$output" ] \ + || { echo "a generator is run outside the project's toolchain:"; echo "$output"; false; } + + run grep -c 'mise exec -- bash -c "\$2"' "${SCAFFOLD_ROOT}/lib/adapter.sh" assert_ok [ "$output" -eq 1 ] } diff --git a/tests/service.bats b/tests/service.bats index 411090f..75b5de4 100644 --- a/tests/service.bats +++ b/tests/service.bats @@ -303,7 +303,12 @@ EOF SCAFFOLD_ROOT="$toolbox" run apply_service_drivers "$app" "$app" fixture broken [ "$status" -eq 1 ] - [[ "$output" == *"the broken driver failed for fixture"* ]] + # The service and the family both have to be named: a bare "a driver failed" + # sends the reader to the wrong one of eight. + [[ "$output" == *"wiring broken into"* ]] \ + || { echo "the failure does not name the service:"; echo "$output"; false; } + [[ "$output" == *"fixture driver"* ]] \ + || { echo "the failure does not name the driver family:"; echo "$output"; false; } [ ! -e "${app}/installed" ] } @@ -330,7 +335,12 @@ EOF SCAFFOLD_ROOT="$toolbox" run apply_service_drivers "$app" "$app" fixture careless [ "$status" -eq 1 ] - [[ "$output" == *"the careless driver failed for fixture"* ]] + # The service and the family both have to be named: a bare "a driver failed" + # sends the reader to the wrong one of eight. + [[ "$output" == *"wiring careless into"* ]] \ + || { echo "the failure does not name the service:"; echo "$output"; false; } + [[ "$output" == *"fixture driver"* ]] \ + || { echo "the failure does not name the driver family:"; echo "$output"; false; } [ ! -e "${app}/installed" ] } From f41d60a180713374fa330be10704b996432c0b8a Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 00:17:17 +0700 Subject: [PATCH 4/5] fix(ci): let a fixed pull request body turn the check green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body check lived in ci.yml, which runs on opened and synchronize. A body that fails it is fixed by editing the body — which ci.yml does not run on — so the check stayed red for a fix that had already been made. Observed on this branch's own pull request. Its own workflow rather than adding `edited` to ci.yml: that would re-run the integration lane, twenty minutes, every time somebody touched a description. --- .github/workflows/ci.yml | 28 ------------------- .github/workflows/pull-request.yml | 43 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/pull-request.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04bff16..8ab18f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,31 +74,3 @@ jobs: # tests/workflows.bats asserts sha-pinned actions, closed permission # sets and shared-repository-only `uses:`. No audit runs on them. - run: mise exec -- zizmor .github/workflows/ - - # The checklist is only worth having if something reads it. This parses the - # headings out of the template itself rather than holding a second copy of - # them, so editing the template changes what is enforced — the convention and - # its enforcement cannot drift apart. Borrowed from immich's auto-close.yml. - pull-request-body: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - env: - BODY: ${{ github.event.pull_request.body }} - run: | - missing="" - while IFS= read -r heading; do - printf '%s\n' "$BODY" | grep -qF "$heading" || missing="${missing}\n ${heading}" - done < <(grep '^## ' .github/pull_request_template.md) - - if [ -n "$missing" ]; then - printf 'pull request body is missing:%b\n' "$missing" >&2 - echo "Keep the template's sections; delete the comments, not the headings." >&2 - exit 1 - fi diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..39eae60 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,43 @@ +name: Pull request +# Its own workflow, not a job in ci.yml, because of `edited`: a body that fails +# this check is fixed by editing the body, and ci.yml does not run on that — +# so the check stayed red for a fix that had already been made. Putting +# `edited` in ci.yml instead would re-run the integration lane, twenty +# minutes, every time somebody touched a description. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + # The checklist is only worth having if something reads it. This parses the + # headings out of the template itself rather than holding a second copy of + # them, so editing the template changes what is enforced — the convention and + # its enforcement cannot drift apart. Borrowed from immich's auto-close.yml. + body: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - env: + BODY: ${{ github.event.pull_request.body }} + run: | + missing="" + while IFS= read -r heading; do + printf '%s\n' "$BODY" | grep -qF "$heading" || missing="${missing}\n ${heading}" + done < <(grep '^## ' .github/pull_request_template.md) + + if [ -n "$missing" ]; then + printf 'pull request body is missing:%b\n' "$missing" >&2 + echo "Keep the template's sections; delete the comments, not the headings." >&2 + exit 1 + fi From a12a3aac4ab20f72076ff237a9d286822cd781a3 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 00:34:15 +0700 Subject: [PATCH 5/5] fix(ci): keep the name main's branch protection requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the body check to its own workflow was safe; renaming the job to `body` in the same commit was not. main requires a status check called pull-request-body, and a required check that never reports blocks every merge — including the pull request carrying the rename, which is how this was found. The name is now commented as load-bearing. Nothing in this repository records that setting, which is exactly what ADR-0004 says about the one guardrail that is not a file. --- .github/workflows/pull-request.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 39eae60..dcb125f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -19,7 +19,16 @@ jobs: # headings out of the template itself rather than holding a second copy of # them, so editing the template changes what is enforced — the convention and # its enforcement cannot drift apart. Borrowed from immich's auto-close.yml. - body: + # + # The job's name is load-bearing: `main`'s branch protection requires a + # check called `pull-request-body`, and a required check that never reports + # blocks every merge forever. Moving the job to this workflow was safe; + # renaming it to `body` at the same time was not, and blocked the pull + # request that made the change. Renaming it means updating the repository + # setting first — there is no trace of that setting in this repository to + # remind anyone, which is ADR-0004's point about the guardrail that is not + # a file. + pull-request-body: runs-on: ubuntu-latest permissions: contents: read