Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
35f5e2e
Match composer patch versions by release identity
mikolalysenko Sep 26, 2026
b700809
Test exact Composer releases on every OS
mikolalysenko Sep 26, 2026
bbf1949
Keep Composer patches installed on every version
mikolalysenko Sep 26, 2026
6fd093b
Keep CRLF composer.lock line endings when vendoring
mikolalysenko Sep 26, 2026
e59b605
Match SBOM-padded Composer date versions
mikolalysenko Sep 26, 2026
452e2d9
Strip Composer dist mirrors before redirecting
mikolalysenko Sep 26, 2026
98d705f
Edit only the vendored entry in composer.lock
mikolalysenko Sep 26, 2026
b9fb886
Match vendored Composer ownership by release
mikolalysenko Sep 26, 2026
fe912c0
Do not report source-installed Composer as fixed
mikolalysenko Sep 26, 2026
165cebb
Name the vendor dir in Composer reinstall hints
mikolalysenko Sep 26, 2026
5408586
Run Composer compatibility on every related change
mikolalysenko Sep 26, 2026
0cf30f9
Satisfy clippy in the Composer lock splice
mikolalysenko Sep 26, 2026
ff64d02
Read composer.json through the VEX discovery context
mikolalysenko Sep 26, 2026
9343c4f
Check Composer source and mirror layouts against the redirect oracle
mikolalysenko Sep 27, 2026
68c8c82
Run Composer compatibility when the crawler oracle or group commit ch…
mikolalysenko Sep 27, 2026
dacaf8f
Merge origin/main (#276) into the Composer patch fold
mikolalysenko Sep 27, 2026
7abd927
Merge origin/main (b32711f7) into fix/composer-patch-annotations
mikolalysenko Sep 27, 2026
185f91a
Treat a Composer date release's trailing .0 parts as padding
mikolalysenko Sep 27, 2026
8b88583
Match package/-prefixed patch keys and guard reads in mirror filters
mikolalysenko Sep 27, 2026
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
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -724,9 +724,12 @@ jobs:
# coverage.
#
# composer: 1 (packagist stopped serving composer 1 on 2025-09-01,
# so the fixture resolves from an inline repository), 2.2 LTS (the
# other release with the git-source download fallback the hosted
# redirect must drop) and current 2.
# so the fixture resolves from an inline repository), 2.2 LTS and
# current 2. Every release from 1.x through 2.9 falls back to the
# git `source` when the dist download fails (the fallback the
# hosted redirect must drop); 2.10 does not. The exact-release
# matrix, 2.9.8 and macOS/Windows included, is
# composer-compatibility.yml.
- {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2'}
- {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2.2'}
- {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '1'}
Expand Down
213 changes: 213 additions & 0 deletions .github/workflows/composer-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
name: Composer patch compatibility

# Exact Composer releases on ubuntu, windows and macOS through the real
# vendored (`e2e_vendor_composer_build`) and hosted
# (`e2e_redirect_composer_build`) capstones, each ending in the
# manifest-less VEX step, plus the hermetic `composer::` VEX cells.
#
# Release boundaries: 1.10.28 = Composer 1 (inline-repository fixture,
# packagist dropped Composer 1 metadata); 2.0.14 / 2.1.14 = the 2.x lines
# whose path mirror still drops `.gitignore`d files; 2.2.30 = LTS;
# 2.5.8 / 2.8.12 = mid 2.x; 2.9.8 = the last release whose dist failure
# falls back to `source`, so the hosted redirect's source drop is load
# bearing there; 2.10.3 = current, no fallback.
#
# PHP: Composer 1 never on 8.5 (broken there). No 7.4 cell: the psr/log
# 3.0.2 capstones need PHP >= 8.0; the depscan harness covers 7.4.
#
# Each leg runs one checksum-pinned composer.phar through `php` (the
# capstones' SOCKET_PATCH_COMPOSER_PHAR mode), which is also what makes
# Windows work: `Command::new("composer")` cannot resolve `composer.bat`.

on:
pull_request:
paths:
- '.github/workflows/composer-compatibility.yml'
- 'tests/docker/Dockerfile.composer'
- 'Cargo.lock'
- 'Cargo.toml'
- 'crates/*/Cargo.toml'
- 'crates/socket-patch-core/src/vendor/**'
- 'crates/socket-patch-core/src/patch/**'
- 'crates/socket-patch-core/src/manifest/**'
- 'crates/socket-patch-core/tests/fixtures/redirect/composer/**'
- 'crates/socket-patch-core/tests/fixtures/composer-version-vectors.json'
- 'crates/socket-patch-core/src/crawlers/composer_crawler.rs'
- 'crates/socket-patch-core/src/crawlers/composer_crawler/**'
- 'crates/socket-patch-core/src/utils/composer*.rs'
- 'crates/socket-patch-core/src/utils/purl.rs'
- 'crates/socket-patch-core/src/utils/group_commit.rs'
- 'crates/socket-patch-core/src/utils/durability.rs'
- 'crates/socket-patch-core/src/vex/**'
- 'crates/socket-patch-cli/src/commands/vendor*'
- 'crates/socket-patch-cli/src/commands/get*.rs'
- 'crates/socket-patch-cli/src/commands/apply.rs'
- 'crates/socket-patch-cli/src/commands/rollback.rs'
- 'crates/socket-patch-cli/src/commands/remove.rs'
- 'crates/socket-patch-cli/src/commands/composer_hints.rs'
- 'crates/socket-patch-cli/src/commands/scan/**'
- 'crates/socket-patch-cli/src/commands/vex*.rs'
- 'crates/socket-patch-cli/tests/e2e_*composer*.rs'
- 'crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs'
- 'crates/socket-patch-cli/tests/composer_e2e_common/**'
- 'crates/socket-patch-cli/tests/docker_vendor_common/**'
- 'crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs'
- 'crates/socket-patch-cli/tests/vex_e2e_common/**'
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: composer-compat-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
SOCKET_NO_CONFIG: '1'
SOCKET_NO_UPDATE_CHECK: '1'

jobs:
native:
name: composer ${{ matrix.composer }} / php ${{ matrix.php }} / ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- {os: ubuntu-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a}
- {os: ubuntu-latest, composer: '2.0.14', php: '8.0', sha256: 29454b41558968ca634bf5e2d4d07ff2275d91b637a76d7a05e6747d36dd3473}
- {os: ubuntu-latest, composer: '2.1.14', php: '8.1', sha256: d44a904520f9aaa766e8b4b05d2d9a766ad9a6f03fa1a48518224aad703061a4}
- {os: ubuntu-latest, composer: '2.2.30', php: '8.1', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2}
- {os: ubuntu-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2}
- {os: ubuntu-latest, composer: '2.5.8', php: '8.2', sha256: f07934fad44f9048c0dc875a506cca31cc2794d6aebfc1867f3b1fbf48dce2c5}
- {os: ubuntu-latest, composer: '2.8.12', php: '8.4', sha256: f446ea719708bb85fcbf4ef18def5d0515f1f9b4d703f6d820c9c1656e10a2f2}
- {os: ubuntu-latest, composer: '2.9.8', php: '8.4', sha256: 59b2c50e10cafa0d8efc19ede9a326d782f096c674a26baf98cf042ce23de890}
- {os: ubuntu-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6}
- {os: windows-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a}
- {os: windows-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2}
- {os: windows-latest, composer: '2.9.8', php: '8.4', sha256: 59b2c50e10cafa0d8efc19ede9a326d782f096c674a26baf98cf042ce23de890}
- {os: windows-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6}
- {os: macos-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a}
- {os: macos-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2}
- {os: macos-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- run: rustup show
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: composer-compat
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
tools: none
extensions: zip, mbstring, curl, openssl
ini-values: memory_limit=-1
coverage: none
- name: Download and verify composer ${{ matrix.composer }}
shell: bash
env:
COMPOSER_RELEASE: ${{ matrix.composer }}
COMPOSER_PHAR_SHA256: ${{ matrix.sha256 }}
# The pinned digest is the contract; the published .sha256sum is a
# second, independent check. php computes the digest so the step
# needs no sha256sum/shasum on any runner image.
run: |
set -euo pipefail
dir="$RUNNER_TEMP"
if command -v cygpath >/dev/null 2>&1; then
dir="$(cygpath -m "$RUNNER_TEMP")"
fi
phar="$dir/composer-$COMPOSER_RELEASE.phar"
base="https://getcomposer.org/download/$COMPOSER_RELEASE/composer.phar"
curl -fsSL --retry 3 -o "$phar" "$base"
published="$(curl -fsSL --retry 3 "$base.sha256sum" | cut -d' ' -f1)"
# shellcheck disable=SC2016 # $argv is PHP, not shell
actual="$(php -r 'echo hash_file("sha256", $argv[1]);' "$phar")"
if [ "$actual" != "$COMPOSER_PHAR_SHA256" ] || [ "$actual" != "$published" ]; then
echo "::error::composer $COMPOSER_RELEASE phar sha256 $actual (pinned $COMPOSER_PHAR_SHA256, published $published)"
exit 1
fi
reported="$(php "$phar" --version --no-ansi)"
echo "$reported"
case "$reported" in
*"Composer version $COMPOSER_RELEASE "*) ;;
*) echo "::error::expected composer $COMPOSER_RELEASE"; exit 1 ;;
esac
echo "SOCKET_PATCH_COMPOSER_PHAR=$phar" >> "$GITHUB_ENV"
- name: Real-composer vendored + hosted flows with manifest-less VEX
shell: bash
env:
SOCKET_PATCH_COMPOSER_E2E_REQUIRED: '1'
SOCKET_PATCH_COMPOSER_E2E_VERSION: ${{ matrix.composer }}
# The build capstones are `#[ignore]`-gated (the unpinned `test` job
# skips them). The hermetic `composer::` VEX cells are a module of
# the shared `e2e_vex_lockfile` binary; their filter goes in a second
# command so it does not also filter the build suites.
run: |
cargo test -p socket-patch-cli --no-fail-fast \
--test e2e_vendor_composer_build --test e2e_redirect_composer_build \
-- --ignored --nocapture
cargo test -p socket-patch-cli --test e2e_vex_lockfile -- composer:: --nocapture

docker:
# The vendored Docker capstone against an exact composer image. Its
# fixture resolves psr/log from packagist, which no longer serves
# Composer 1, so 1.10.28 is covered by the native legs only.
name: docker composer ${{ matrix.composer }}
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
include:
- {composer: '2.2.30', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2}
- {composer: '2.10.3', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Docker Buildx
# `driver: docker` so the composer image's
# `FROM socket-patch-test-base:latest` resolves against the host
# daemon (as in ci.yml's e2e-docker job).
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
with:
driver: docker

- name: Install Rust
run: rustup show

# No `actions/cache`: this job builds Docker images (zizmor
# cache-poisoning audit), as in ci.yml's e2e-docker job.

- name: Build base image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: tests/docker/Dockerfile.base
tags: socket-patch-test-base:latest
load: true

- name: Build composer ${{ matrix.composer }} image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: tests/docker/Dockerfile.composer
build-args: |
COMPOSER_VERSION=${{ matrix.composer }}
COMPOSER_SHA256=${{ matrix.sha256 }}
tags: socket-patch-test-composer:latest
load: true

- name: Run the vendored composer Docker capstone
env:
SOCKET_PATCH_DOCKER_E2E_REQUIRED: '1'
run: cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_vendor_composer
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ to **six flavors**.
| npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm/<uuid>/[@scope/]<name>-<version>/node_modules/<name>/` (the extra `node_modules/<name>` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `<uuid>/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `<uuid>/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:<path>`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install <new>`, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift |
| cargo | crate dir `<name>-<version>/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `<version>+socket.<uuid>` — `<core>+<meta>.socket.<uuid>` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `<name>-socket-<first 8 hex of the uuid>` with `package = "<name>"` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need `--offline` on cargo 1.56 and a populated registry index (or network access) on older cargo such as 1.41, which loads the index to tell them apart. Note: path deps build **without** `--cap-lints allow` |
| golang | module dir `<module>@<version>/` | `go.mod` `replace <module> <ver> => ./.socket/vendor/golang/<uuid>/<module>@<ver>` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) |
| composer | package dir `<vendor>/<name>@<version>/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update <pkg>` reverts it |
| composer | package dir `<vendor>/<name>@<version>/`; the copy's `.gitignore` / `.hgignore` are emptied and its `.gitattributes` `export-ignore` rules dropped, because Composer's path mirror skips the files they match (`vendor_composer_mirror_filters_neutralized`; a patch that rewrites one of them is refused `vendor_composer_mirror_filter_conflict`). Re-runs heal copies vendored before this | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: "<patch-uuid>"}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). Composer 1 does not reinstall an already-installed package whose dist changed: remove `vendor/<vendor>/<name>` first. `composer update <pkg>` reverts it. See `docs/testing/composer-compatibility.md` |
| gem | gem dir `<name>-<version>/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes `<name> (= <ver>)!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair |
| pypi / uv (uv.lock) | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated) | `[tool.uv.sources] <name> = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies` | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock) |
| pypi / poetry (poetry.lock: legacy `[metadata.hashes]`, lock 1.0/1.1 `[metadata.files]`, 2.x `files`) | (rebuilt wheel) | lock-only: the target `[[package]]` gets `[package.source] type="file"` (+ `reference = ""` on the 0.12/1.0 layouts, which read it unconditionally) and the single `{file, hash: sha256-of-our-wheel}` entry in whichever table the generation keeps it. pyproject + `metadata.content-hash` untouched; CRLF locks keep their line endings. A lock written by Poetry < 1.4 emits `pypi_poetry_integrity_unverified` (that installer verifies no local hashes and skips an already-installed version) | `poetry check --lock && poetry sync`, cold cache (hash fail-closed from Poetry 1.4; byte-stable lock) — see `docs/testing/poetry-compatibility.md` |
Expand Down
Loading
Loading