Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 52 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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.
#
# 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
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
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
20 changes: 13 additions & 7 deletions lib/adapter.sh
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +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).
( cd "$parent" && APP_DIR="$(basename "$dest")" \
npm_config_frozen_lockfile=false \
mise exec -- bash -c "$ADAPTER_GENERATOR" )
# 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 "$in_the_app_toolchain" _ "$parent" "$ADAPTER_GENERATOR"

verify_workspace_filter_name "$dest"

Expand Down Expand Up @@ -173,10 +179,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 "$in_the_app_toolchain" _ "$dest" "$ADAPTER_POST_GENERATE"
fi

# After post-generate: the generator and its own follow-up have settled the
Expand Down
35 changes: 35 additions & 0 deletions lib/log.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,38 @@
log() { printf '%s\n' "$*" >&2; }
warn() { printf 'warning: %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }

# step <description>
# 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 <what-for> <command>...
# 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"
}
108 changes: 108 additions & 0 deletions lib/manifest.sh
Original file line number Diff line number Diff line change
@@ -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 <project> <relative-path>
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 <project>
collect_config_roots() {
sed -n '/^config_roots = \[$/,/^\]$/p' "${1}/mise.toml" \
| sed -n 's/^ "\(.*\)",$/\1/p'
}
# sync_ci_roots <project> — 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 <project> <rel> — 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
}
Loading