Skip to content

1.9.79: the container image installs composer.lock's set, not a fresh resolution - #312

Merged
tracking202 merged 4 commits into
1.9.79from
1.9.79-docker-image-locked-deps
Oct 28, 2026
Merged

tracking202 merged 4 commits into
1.9.79from
1.9.79-docker-image-locked-deps

Conversation

@tracking202

@tracking202 tracking202 commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #306 (1.9.79-tests-not-served): it edits the same Docker Image workflow and .dockerignore's neighbourhood, so this branch starts from #306's head (c26911b) and must merge after #306. Until then the diff against 1.9.79 also shows #306's commit; this PR's own change is 2e147be.

Backlog: E016

The defect

The Dockerfile's app stage ran COPY composer.json /var/www/html/composer.json and then composer install --no-dev …, under a comment saying no composer.lock is committed. One is (and .dockerignore does not leave it out). So every image build resolved whatever matched composer.json that day, while the release zip ships the locked set: two images of one release could run different dependencies, an upstream release could change a production image with no commit, and no image could be rebuilt as it was. That matters now that #145 publishes the image on release.

Measured on 10/28, the old shape (composer install --no-dev with composer.json alone, dry run) resolves six packages away from the lock: composer/ca-bundle 1.6.0 (lock 1.5.14), symfony/console v6.4.48 (v6.4.46), symfony/string v7.4.21 (v7.4.19), and the intl-grapheme, intl-normalizer and mbstring polyfills at 1.43.x (lock 1.41.0, 1.42.0, 1.38.2).

What changed

  • Dockerfile, app stage. COPY composer.json composer.lock /var/www/html/, then one RUN: composer validate --no-check-publish --no-check-all && composer install --no-dev …. The vendor layer is cached on both files. The comment is rewritten.
    • Why validate: composer install only warns on a lock that has drifted from composer.json. Measured with Composer 2.8.12 on five stale variants:
      • A changed constraint the lock still satisfies, a changed config.platform, and an added require-dev each install the old set with exit 0.
      • An added require and an unsatisfiable constraint fail install with exit 4.
      • composer validate exits 2 on all five.
    • The flags are the ones pr-checks.yml and package-release.sh already use.
    • Why the files are named: with composer.lock planted in .dockerignore, BuildKit fails the named COPY ("/composer.lock": not found). COPY composer.* … copies composer.json alone and carries on without a word.
  • build/scripts/docker-entrypoint.sh. Its install at startup runs when vendor/ is missing, which means the base image with a checkout bind-mounted. It now:
    • refuses a missing composer.lock;
    • runs the same composer validate first, and under set -e a stale lock stops the container.
  • Docker Image workflow. A new step runs the release zip's own vendor check inside the built image against the checkout's composer.lock:
    • The check is build/scripts/release-tree.php vendor <app dir> <lock>, verify's vendorMatchesLock() exposed as a subcommand. One implementation of "vendor/ is exactly the locked runtime set, no dev package" ([Setup] Include ISP preference in rotator #24).
    • The Build step also plants vendor/p202-context-marker in the build context. The check then asserts the marker is absent from the image, so a builder's own vendor/ (with the dev set) cannot reach the image.
    • release-tree.php and ReleaseTree.php are added to the workflow's paths.
  • tests/Release/ImageInstallsTheLockedSetTest, the machine check:
    • It reads the Dockerfile by its instructions: continuations joined, stages and WORKDIR followed through FROM base AS app. The install RUN must directly follow COPY composer.json composer.lock <WORKDIR>/ and be a validate && install --no-dev chain of plain words.
    • It refuses by name what it does not read near composer: ;/|/quotes/$, RUN flags, heredocs, JSON forms, an escape directive, SHELL, unknown instructions, ADD/COPY --from of composer's files or vendor/, -d/--working-dir, update/require, a flag before the subcommand, ENV/ARG COMPOSER* other than COMPOSER_ALLOW_SUPERUSER, and "config": {"lock": false} in composer.json. I measured that last one: with it set, composer ignores the lock and both validate --check-lock and install pass with a fresh resolution.
    • 29 planted shapes, the 1.9.78 COPY among them, each fail with their own message. Three equivalent spellings pass.
    • It also runs the entrypoint itself:
      • cd /var/www/html is redirected by an exported bash function.
      • composer is a stub that execs the real composer for validate and stands in for install.
      • Four cases: fresh lock, drifted lock (exit 2, install never runs), no lock, vendor/ already installed.
  • 1.9.79: the container image ships no test suite, and its runners refuse the web #306's TestScriptsAreNotServedTest reads the Dockerfile's COPY sources, so it now also holds .dockerignore to sending composer.lock. With the lock planted in .dockerignore it fails (.dockerignore leaves out composer.lock).
  • Docs: build/README.md (Dockerfile and entrypoint rows, boot chain, the "lock file is not up to date" boot failure), RELEASING.md, documentation/deploying-on-coolify.md. A changelog line in both files.

Sweep: every place that installs the app's dependencies

Where Verdict
Dockerfile app stage Fixed above.
Dockerfile base stage Installs no app dependencies. It copies the composer binary from composer:2, a moving tag. That decides no package version (the lock does), but it does write the autoloader. Not pinned here: out of this item, raised for a backlog item.
docker-entrypoint.sh Validates and refuses a missing lock (above).
docker-compose.yaml Builds target: base with the checkout bind-mounted, so the entrypoint path above.
docker-compose.coolify.yaml Builds the default app stage, so the fix applies.
Coolify kit and image publish (#145) Not on 1.9.79 (#145 is open). Its release job builds this Dockerfile's app stage, so it inherits the fix, and its template pulls that image.
build/scripts/package-release.sh Already runs composer validate and then installs from the lock; its verify holds vendor/ to the lock.
install.sh (host and docker paths) Runs composer install in a checkout or the release zip, both of which carry composer.lock, so it installs the locked set. It does not validate. Left unchanged, see "For the reviewer".
CI workflows (php-unit, php-lint, …) composer install from the tracked lock; pr-checks.yml validates it on every PR.

How it was proved here

  • Composer outside Docker, from a scratch copy with the session's composer (2.8.12) and network: composer install --no-dev from the lock installed the 22 locked packages (dist downloads were refused by the proxy, so it fell back to git source). The stale-lock measurements are listed above.
  • BuildKit, on a private dockerd (data under my scratch dir, now stopped and removed):
    • Setup: a FROM scratch build whose COPY is the Dockerfile's line character for character, with the worktree as context, exported with --output type=local.
    • The layer the install RUN sees holds exactly composer.json and composer.lock, byte-identical to the checkout's.
    • The worktree's real vendor/ (7,162 files) is not in the context.
    • The named-versus-glob result is the one listed under "Why the files are named" above.
  • The app stage's steps replayed in order outside Docker, on the files BuildKit exported:
    • the two-file layer, then validate && install with the Dockerfile's flags, then the full context copied over, then dump-autoload --optimize --no-dev;
    • release-tree.php vendor against the checkout's lock answered ok;
    • with 'dev' => false planted to true in installed.php, it refused;
    • with a lock carrying one changed version, one added package and one removed, it reported all three, exit 1;
    • php -S on the tree answered /health/ with 200.
  • Plants against the real files (backed up and restored with cmp):
    • The 1.9.78 COPY and RUN put back into the Dockerfile fail testTheDefaultStageInstallsTheLockedSet. So does the old COPY alone.
    • Removing the entrypoint's validate fails the fresh-lock and drifted-lock tests. Removing its missing-lock guard fails the no-lock test.
  • actionlint 1.7.7 (with shellcheck) on the workflow: clean. shellcheck on the entrypoint: clean.

The image build itself ran only in CI, in this PR's Docker Image workflow run. The base stage's apt, pecl and composer steps cannot reach their hosts through this sandbox's proxy, and the Dockerfile was not changed to make it build here.

Verification scope report

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, with actionlint 1.7.7 on PATH (unit: Tests: 6194, Assertions: 133456, Skipped: 4., the 4 documented DB-singleton skips):

Scope report
------------
  actionlint PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

What could not run:

  • The image build and the new workflow step: only in CI, as above.
  • A live instance: not stood up, since no app code path changed. The user-facing path is building the image, which is CI's. The licence stub being down did not matter here.
  • No agent eval: nothing agent-facing changed.

For the reviewer

  • The entrypoint now stops the dev stack's container when the bind-mounted checkout has no composer.lock, or one that has drifted from composer.json, where before it installed with a warning. The README's boot section says what to run.
  • The structural test is deliberately rigid. The install RUN must directly follow the two-file COPY, as one && chain. A cache mount (RUN --mount=type=cache) or SHELL ["/bin/bash", "-o", "pipefail", "-c"] will be refused until the reader is taught them. That is the loud direction, and the message says so.
  • ReleaseTree::verifyVendor() is split. The lock-existence message for the release tree is unchanged, and vendorMatchesLock(root, lockFile) is public. main()'s problem printing moved into report() so the vendor command can run without loading the manifest.
  • install.sh was not given a validate step. It installs from the lock it ships with. A step there would need a decision about hosts with an older system Composer. Its non-verbose mode also hides composer's stale-lock warning in a log it shows only on failure; that is raised for a backlog item.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

…se the web

The app stage of the Dockerfile copies the checkout into the web root, and
.dockerignore did not leave tests/ out, so every built image (the one the
Coolify and production compose stacks deploy) served the test suite. About
thirty scripts there are the child processes of integration tests and the
live passes' drivers: they act on the database their environment or their
defaults name, with no sign-in. Most stop over HTTP before acting only
because $argv is a CLI-only global; measured on php -S against a scratch
database, a GET of the schema installer's runner created 164 tables and a
GET of the ladder runner ran the upgrade.

- .dockerignore leaves tests/ out. Docker's own context transfer (a FROM
  scratch stage with the app stage's COPY, exported) carried 611 PHP files
  under tests/ before and none after, the app files still there. The Docker
  Image workflow now checks the built image has no tests/ and that a request
  for a runner is a 404.
- Every runnable script under tests/ (26 of them; tests/live/goals-ingest.php
  already was) refuses any SAPI but the CLI first:
  `if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }`, the shape
  #292's runner uses. Under the CLI nothing changes.
- TestScriptsAreNotServedTest reads every PHP file under tests/ by its
  top-level statements: declarations, imports and a load of a file the
  repository holds are not acts; the first other statement must be the
  guard. The agent-eval router, which runs under php -S on purpose, is
  listed with why. It also holds the release zip to shipping none of these
  scripts, refuses another extension a host may run as PHP, requests every
  guarded script over php -S (each an empty 404, a function library a 200
  as the control), and reads .dockerignore as BuildKit does (checked file
  for file against BuildKit on this tree and on 14 pattern shapes) to hold
  tests/ out of the image's context while the files the Dockerfile copies
  by name stay in.

Backlog: CR7W

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… resolution

The Dockerfile's app stage copied composer.json alone and ran composer
install, so every image build resolved whatever matched composer.json that
day while the release zip ships the locked set. Two images of one release
could run different dependencies, an upstream release could change an image
with no commit, and no image could be rebuilt as it was. Measured on 10/28:
the old shape resolves composer/ca-bundle, symfony/console, symfony/string and
three polyfills to newer versions than the lock's. Its comment said no lock
was committed; one is.

- The app stage copies composer.json and composer.lock (named, so a lock left
  out of the context fails the build where a glob would skip it in silence;
  measured through BuildKit) and runs composer validate before
  composer install --no-dev in one layer keyed on both files. install only
  warns on a lock that has drifted from composer.json (measured: a changed
  constraint the lock still satisfies, a changed platform and an added
  require-dev all install the old set with exit 0); validate exits 2 on each.
- The entrypoint's install at startup (the base image with a checkout
  bind-mounted) refuses a missing lock and runs the same validate first.
- The Docker Image workflow checks the built image's vendor/ against the
  checkout's composer.lock with the release zip's own check, exposed as
  release-tree.php vendor <app dir> <lock>, and plants a vendor/ in the
  build context to show .dockerignore keeps a builder's own out.
- ImageInstallsTheLockedSetTest reads the Dockerfile by its instructions and
  holds the install RUN to directly following that COPY into the WORKDIR, as
  a validate && install --no-dev chain of plain words; what it does not read
  near composer is refused by name. 29 planted shapes (the 1.9.78 COPY among
  them) each fail with their own message, 3 equivalent spellings pass. It
  runs the entrypoint itself against the real composer validate.
- build/README.md, RELEASING.md and the Coolify guide say so; changelog lines.

The image build itself runs only in CI (the base stage's apt, pecl and
composer steps cannot reach their hosts from the sandbox this was written in).

Backlog: E016

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 28, 2026 10:52
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T10:52:14.302510Z
We couldn’t complete this review. Comment @codex review to try again.
2e147be PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For 2e147be: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For c847f08: 3 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in tests-not-served (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 2eb50c7 into 1.9.79 Oct 28, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants