# Phase 0a — Caleb's runbook: test tooling and `--no-dev`

**Written:** October 22, 2026 · Andrew + Claude
**For:** Caleb, as the updater (`UPDATE_WORKFLOW.md` → *The Standard Update Flow*)
**Plan it implements:** `TESTING_STRATEGY.md` §7, Phase 0a

---

## Why this matters

Caleb owns one PR before automated testing can start. It adds the dev test tools to
`composer.json` / `composer.lock` and makes sure they never reach TEST or PROD. It should take
about an hour, and most of that is reading and checking.

Andrew's side (Phase 0b) is already written: 11 new files on his local branch
`chore/phase0b_harness` (`phpunit.xml`, `phpstan.neon`, `phpcs.xml`, `scripts/test.sh` and three
smoke tests). None of it can run until `vendor/bin/phpunit` exists, and that needs this PR.

The server half matters because `drupal/core-dev` brings PHPUnit, Behat, Mink, PHPStan and PHPCS.
PHPUnit's `eval-stdin.php` (CVE-2017-9841, remote code execution) is one of the most-scanned paths
on the internet. Running `composer install --no-dev` on the servers is what keeps it off them.

---

## Step 1 — Fix the deploy alias before anything else (TEST and PROD)

The deploy aliases have to run `composer install --no-dev` before the PR merges. The docs disagree
about this. `UPDATE_WORKFLOW.md` used to say `ccsDeploy` is just `git pull`, but the Aug 23 deploy
notes say it already runs `composer install`. Neither says whether it passes `--no-dev`.

1. On each server, back up and read the aliases:

   ```bash
   cp ~/.bashrc ~/.bashrc.bak-$(date +%Y%m%d)
   grep -n "alias ccs" ~/.bashrc ~/.bash_aliases ~/.profile 2>/dev/null
   ```

2. Check what is on the box now. If `vendor/drupal/devel` is there, the alias has never passed
   `--no-dev`:

   ```bash
   ls -d vendor/drupal/devel vendor/bin/phpunit 2>&1
   ```

3. Confirm Devel is not enabled. It is not in `config/sync/core.extension.yml`, so removing it
   should be safe. Check anyway:

   ```bash
   PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php \
     vendor/drush/drush/drush.php -r web pm:list --status=enabled --format=list | grep -i devel
   ```

   Expect no output. If Devel shows up, uninstall it with `pm:uninstall devel` before you go on.

4. If the alias's composer step has no `--no-dev`, edit it so it reads:

   ```bash
   PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php $HOME/bin/composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-intl
   ```

   Then `source ~/.bashrc` and re-run the `grep` to confirm. **Fix the alias itself.** Do not rely
   on running `--no-dev` by hand afterwards: the alias runs first and will already have installed
   the dev packages.

5. Paste the exact alias text into `UPDATE_WORKFLOW.md` → *Production Deploy Reference*, where a
   placeholder is waiting for it. Also add `--no-dev` to the manual command under **composer install
   on servers** in `SESSION_HANDOFF.md` *Key Facts*. That command leaves it out today.

---

## Steps 2–5 — Add the test tools locally and open the PR

The PR changes exactly two files, `composer.json` and `composer.lock`, and no production package
should change version. Tell Andrew you are starting, so nobody else runs `composer update` or
`require` at the same time (`UPDATE_WORKFLOW.md` §1).

**Step 2 — Branch and require.** `--with-all-dependencies` will almost certainly be needed, because
`core-recommended` pins exact Symfony versions.

```bash
git checkout main && git pull
git checkout -b chore/phase0a_test_dependencies
ddev composer require --dev "drupal/core-dev:^11.2" --with-all-dependencies
```

Then check what landed:

```bash
ddev composer show drupal/core drupal/core-dev | grep -E "^(name|versions)"
ddev composer show | grep -iE "phpunit|phpstan|coder|codesniffer|mink|behat"
```

- `drupal/core-dev` should be the **same version as `drupal/core`** (11.4.7 today). If it is not,
  re-run the require with `drupal/core-dev:11.4.7`.
- If PHPStan or PHPCS did not come with it, add them:
  `ddev composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/phpstan-deprecation-rules drupal/coder`.
- `phpstan/extension-installer` and `dealerdirect/phpcodesniffer-composer-installer` are already
  allowed plugins. If composer asks about either, stop and check why.

**Step 3 — Prove no production package moved.** A `--dev` require can still bump a package that
ships to PROD. This compares only the non-dev `packages` section of the lockfile with `main`. Run it
on your Mac from the project root:

```bash
python3 - <<'PY'
import json, subprocess
b = {p['name']: p['version'] for p in json.loads(subprocess.check_output(['git','show','main:composer.lock']))['packages']}
a = {p['name']: p['version'] for p in json.load(open('composer.lock'))['packages']}
for n in sorted(set(a) | set(b)):
    if a.get(n) != b.get(n): print(f"{n}: {b.get(n,'-')} -> {a.get(n,'-')}")
print('done')
PY
```

The expected output is just `done`. Any line above it is a production change hiding in a
test-tools PR. Either back it out, or put it in its own commit and name it in the PR. Run
`ddev composer audit` as well.

**Step 4 — Confirm nothing else changed.**

```bash
ddev drush updb -y      # expect: No pending updates.
ddev drush cim -y       # expect: There are no changes to import.
ddev drush cr && ddev drush status
ls vendor/bin/phpunit   # must exist locally
```

`core-dev` has no update hooks and no config, so if `updb` or `cim` has work to do, stop.

**Step 5 — Commit and open the PR.**

```bash
git status --short      # only composer.json and composer.lock
git add composer.json composer.lock
git commit -m "Add test dependencies (drupal/core-dev) for the Phase 0 harness"
git push -u origin chore/phase0a_test_dependencies
```

The PR description should state three things:

- What the alias said in Step 1, and what you changed.
- The Step 3 result: "no production package versions changed", or the list of what did.
- The `core-dev` version.

The `UPDATE_WORKFLOW.md` / `SESSION_HANDOFF.md` edits from Step 1 go in a separate docs PR, so this
one stays at exactly two files.

---

## Step 6 — Deploy, then prove the test tools are absent on both servers

Once the PR is merged, `vendor/bin/phpunit` must be **absent** on TEST and PROD. Andrew's machine
should have it; the servers should not. A code-only deploy is enough, because the PR has no update
hooks or config:

```bash
git status --short                 # clean first (untracked files block the pull)
ccsDeploy && ccsCr                 # TEST; confirm site_instance is "test" first
ccsProdDeploy && ccsProdCr         # PROD
```

Check the pull output. It should show `composer.lock` moving and composer *removing* packages, not
saying "Nothing to install". Then run these on each server:

```bash
ls vendor/bin/phpunit        # MUST be: No such file or directory
ls -d vendor/phpunit         # MUST be: No such file or directory
ls -d vendor/drupal/devel    # MUST be: No such file or directory
ls -d vendor/phpstan/phpstan # MUST be: No such file or directory
```

If any of them exists, the alias is not passing `--no-dev`. Fix the alias, run it again, and repeat
the checks.

Then the usual post-deploy checks:

```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php vendor/drush/drush/drush.php -r web updatedb:status
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php vendor/drush/drush/drush.php -r web config:status --format=json
```

Finally, load the home page and `/user/login`, and check `watchdog:show --type=php` for errors.

---

## Done when

- [ ] Aliases read on TEST and PROD; `--no-dev` confirmed or added
- [ ] Devel confirmed not enabled on either server
- [ ] `drupal/core-dev` locked at the same version as `drupal/core`
- [ ] Step 3 script prints only `done` (or the changes are named in the PR)
- [ ] `updb` and `cim` are both no-ops locally
- [ ] PR contains exactly `composer.json` and `composer.lock`; merged to `main`
- [ ] Deployed to TEST and PROD; `vendor/bin/phpunit` and `vendor/drupal/devel` absent on both
- [ ] Alias text recorded in `UPDATE_WORKFLOW.md`; `--no-dev` added to the manual command in
      `SESSION_HANDOFF.md`
- [ ] Andrew told it has landed

**Nothing is needed from Caleb on Andrew's machine.** Andrew's DDEV setup (PHP 8.3, MariaDB 10.11,
nginx-fpm) already matches what `phpunit.xml` expects. Andrew picks up from here with
`ddev composer install`.

---

## Every Drupal update after this — Caleb, as the updater

The flow in `UPDATE_WORKFLOW.md` (*The Standard Update Flow*) stays the same. It gets four
additions now that the lockfile holds the test tools: core-dev moves with core, the production
diff, the tests, and the server check.

1. **Coordinate.** Tell Andrew you are taking the update. While you hold it, Andrew runs no
   `composer update` or `require`.

2. **Branch, snapshot, update.**

   ```bash
   git checkout main && git pull
   git checkout -b chore/drupal-core-update-N.N.N
   ddev snapshot --name=pre-update

   # Core (security or minor). The pattern also matches drupal/core-dev, so the test tools move with core:
   ddev composer update "drupal/core-*" --with-all-dependencies

   # One contrib module:
   ddev composer update drupal/MODULENAME --with-dependencies
   ```

   After a core update, confirm the two versions match:

   ```bash
   ddev composer show drupal/core drupal/core-dev | grep -E "^(name|versions)"
   ```

   Never update `drupal/core-recommended` by itself. If a contrib update will not resolve because a
   test tool pins a shared library, retry with `--with-all-dependencies`. Do not remove `core-dev`
   to get past it.

3. **Apply locally.**

   ```bash
   ddev drush updb -y && ddev drush cex -y && ddev drush cr
   git checkout config/sync/user.role.beta_tester.yml   # standing rule after every cex
   ```

4. **List what ships to production.** Run the Step 3 script from above. This time lines are
   expected: they are exactly the production packages changing, so paste them into the PR
   description. Then run `ddev composer audit`.

5. **Test.** Click through the checklist in `UPDATE_WORKFLOW.md` §4. Once the test harness is on
   `main`, also run `ddev exec ./scripts/test.sh fast`, or the full suite with no argument for a
   core update.

6. **Commit and PR.** Run `git status --short` first, then:

   ```bash
   git add composer.json composer.lock config/sync/
   git commit -m "Update Drupal core to X.Y.Z (security release)"
   git push -u origin chore/drupal-core-update-N.N.N
   ```

7. **Deploy TEST, then PROD.** `ccsDeploy && ccsUpdb && ccsCim && ccsCr`, then the `ccsProd…`
   chain. The fixed alias runs `composer install --no-dev`. **Never run plain `composer install`, or
   any `composer update`, on a server.** After each deploy, `ls vendor/bin/phpunit` must say
   *No such file or directory*.

---

## Andrew's side — the consumer

Andrew gets the test tools from Caleb's lockfile and never adds them himself, so there is nothing
to conflict. `composer.lock` has two lists:

- `packages` — shipped everywhere.
- `packages-dev` — the test tools.

`--no-dev` is chosen when someone runs `install`, not baked into the file. The servers skip
`packages-dev`, and Andrew's DDEV installs both lists.

| Where | Command | Gets the test tools? |
| --- | --- | --- |
| Andrew's DDEV | `ddev composer install` | Yes |
| Caleb's DDEV | `ddev composer install` | Yes |
| TEST and PROD | alias: `composer install --no-dev` | No |

After each merge from Caleb:

```bash
git checkout main && git pull
ddev composer install
ddev drush updb -y && ddev drush cim -y && ddev drush cr
ls vendor/bin/phpunit      # must exist locally
git status --short         # composer.lock must NOT show as modified
```

Three rules keep it conflict-free:

1. **Andrew never runs `composer require`, `require --dev` or `update`.** Each of those rewrites
   `composer.lock`. If another dev tool is needed, it goes to Caleb as an update like any other.
2. **If `composer.lock` shows as modified after an install**, something resolved instead of
   installing. Run `git checkout composer.lock && ddev composer install`.
3. **Feature branches rebase onto `main` after a lockfile lands.** Take `main`'s `composer.lock`,
   then run `ddev composer install`. Never hand-merge the lockfile.
