Composer scan
Which of a site's drupal/* packages need a new release for a target core,
and which have none? One POST with the site's composer files answers for every
package. With patches: true the same call judges each composer patch against
the release its package would install.
What is kept
What you send is cached at the service to improve it: the composer fields this page describes, and the text of each patch. Nothing beyond what was sent is kept, and what is sent is the filtered minimum described here.
POST /v1/composer/scan
{"composer_json": "<composer.json text>",
"composer_lock": "<composer.lock text>",
"target_core": "11.4.5",
"patches": true,
"patch_files": {"patches/webform.patch": "<diff text>"}}
| Field | What it holds |
|---|---|
composer_lock |
Required. The whole file, or a slim form with only drupal/* entries under packages and packages-dev. Each entry has name and version, plus source.reference when the lock has it; a sub-module also has type, require and extra.drupal.datestamp. Without those three, a sub-module comes back as an unknown project. |
composer_json |
Optional. Adds the constraints and extra.patches. |
target_core |
11.4, 11.4.5, or latest for the newest core the site's own constraint allows. Empty scans against the installed core (target_is_installed): what can be updated without a core upgrade. |
installed_core |
Optional. Composer name to the drupal/core requirement its installed release declares, read from the site's vendor directory. That release is then judged by what it declares. |
Constraints are read by composer's own semver library. Body at most 32 MB, at most 400 packages.
The slim lock
A lock with only the fields the scan reads, a few KB instead of hundreds.
source.reference is the commit the installed release was cut from; with
patches: true and a target core, a re-roll tries it first as its merge
base. type, require and extra.drupal.datestamp pair a sub-module with
the project that provides it: drupal.org packages a sub-module as a
metapackage built from its project's release, so the two share a version
and a datestamp, and the sub-module requires the project.
{"packages": [
{"name": "drupal/core", "version": "10.6.9"},
{"name": "drupal/webform", "version": "6.2.9",
"source": {"reference": "3f2c1a9e7b0d4c6a8e1f2b3c4d5e6f7a8b9c0d1e"}},
{"name": "drupal/domain", "version": "3.0.1", "type": "drupal-module",
"extra": {"drupal": {"datestamp": "1778231514"}}},
{"name": "drupal/domain_access", "version": "3.0.1", "type": "metapackage",
"require": {"drupal/core": "^10.2 || ^11", "drupal/domain": "*"},
"extra": {"drupal": {"datestamp": "1778231514"}}}
],
"packages-dev": [{"name": "drupal/devel", "version": "5.3.2"}]}
Only a metapackage needs require, and only its drupal/ entries.
Upload
The same call as multipart/form-data, for a shell. The files go from
disk to the api and never pass through a model's context. curl alone is
enough.
curl -sS https://api.tresbien.tech/v1/composer/scan \
-F composer_json=@composer.json -F composer_lock=@composer.lock \
-F target_core=11.4.5 -F patches=1 \
-F 'patches/a.patch=@patches/a.patch' \
-F 'https://www.drupal.org/files/issues/3398797-21.patch=@3398797-21.patch'
| Part | What it holds |
|---|---|
composer_lock |
Required. The file. |
composer_json |
The file. Optional; without it there are no constraints and no patches. |
target_core |
As for the JSON body. |
patches |
1 or true turns on the patch half. |
| any other name | One patch file. The part's name is the source as the declaration writes it: the local path, or the URL the file was fetched from. A merge request .patch also sends its .diff sibling under its own URL. |
The answer is the compact plan: counts, the packages that need work, one
row per patch that needs a decision, missing_files for the patches not
sent, and next_step. Without patches, the answer is the release scan
as for the JSON body. The caps and limits of the JSON body apply.
The patch half
patches: true judges every declared patch and adds a plan object to the
response. It runs git apply against the release tag, so it has its own rate
budget and must be asked for.
| Field | What it holds |
|---|---|
patch_files |
The text of every patch listed in the declarations, keyed by the source as written: the path or its base name, or the URL. The service downloads nothing. |
candidates |
Composer name to version: the release the caller's own composer picked for each package. When sent, it decides which release a patch is judged against. Each row says which decided it in decided_by. A candidate the lock does not install, or a value that is not a version, is a 400. |
patch_config |
[{package, title, source}]: the declarations the caller resolved from its own patch manager. When sent, they replace what extra.patches says. |
reroll |
true adds a re-rolled diff to every conflicts row under result.reroll. It has status (clean or conflicts), the diff in patch, and verified when the service apply-checked it. Each one costs a scratch merge. |
A package's patches are judged in the order they are declared. Send
patch_config in that order. Packages have no order between them. At most
200 patches. Sending patch_files, candidates or patch_config without
patches: true is a 400.
Response
{target_core, core_installed, bundle_date, counts, rows, patches, unlocked, outside_drupal},
plus plan when the patch half ran.
| Field | What it holds |
|---|---|
rows |
One row per package, ranked: no_release, update, unknown, current. |
rows[] no_release |
No published release supports the target. latest_any and dev_branch say what exists. |
rows[] update |
A compatible release the site does not have: latest, latest_date, latest_core. |
rows[] unknown |
Not a drupal.org project in the bundle. |
rows[] current |
The installed release supports the target and nothing newer is needed. |
counts |
The package status tally. |
patches |
extra.patches as /v1/patch/check items with the installed version filled in. Left out when plan.patches has the same list judged. |
unlocked |
Packages the lock does not install. |
outside_drupal |
Patches declared on packages outside drupal/. They get no row. |
bundle_date |
When the release data was published. A release after it is invisible here. |
plan
{counts, no_release, patches, missing_files, warnings}.
| Field | What it holds |
|---|---|
counts |
The patch verdict tally: merged (the fix is in the release), applies (applies cleanly), conflicts (no longer applies), unknown (no verdict; the row says why). Separate from the top-level counts. |
no_release |
The packages with no installable release for the target. |
patches |
One row per declared patch, in composer.json order, each with the verdict detail. |
patches[].version |
The release the verdict is about. |
patches[].installed |
The release the lock holds, when it differs. |
patches[].note |
For an unknown row: what blocks it. |
patches[].result.core_references |
What the added code references in core, at target_core. The same block /v1/patch/check returns. |
missing_files |
Local patch paths whose text was not sent. |
warnings |
Read before trusting the counts. Each warning opens with the package name. A blocked package whose blocker the site owns gets one that gives the requirement to change: its own constraint, or its minimum stability. |
A patch on a package with no release for the target is judged against the
branch when the lock installs a dev version. Otherwise it is unknown.
Verdicts come from git apply against the release tag; an applies patch
can still be wrong at runtime. The same files and target are answered from
cache, which X-Plan-Cache reports.
Errors
400 for the body, the files or the caps. 503 when the patch check is busy; retry in a few seconds. 502 when the patch check or the constraint resolver could not answer.