Releases: innobraingmbh/composer-soak-time
Release list
v1.10.0
Report release-time provenance in the soak filter output
Builds on v1.9.0 (prefer Packagist's server-stamped published-time) by making the filter show which clock it judged each version by — so you can see, at a glance, how much of the filtering rested on tamper-resistant data versus the backdatable time field.
Without -v
A one-line breakdown is added to the summary:
[Soak Time] Filtered out 151 recent version(s) across 62 package(s). Run with -v for details.
[Soak Time] Judged: 140 by server-stamped publish time, 11 by self-reported time (backdatable), 0 with no release date.
The self-reported count is the residual surface a backdating attacker could still aim at (versions Packagist served no published-time for).
With -v
Every dropped version is annotated with its source and date:
symfony/console — dropped 1 version(s) newer than 168h or without a release date:
8.2.x-dev [published-time 2026-06-09 11:23, server-stamped]
vendor/example — dropped 1 version(s) newer than 168h or without a release date:
9.9.9 [time 2026-06-16 07:09, self-reported (backdatable)]
Notes
- A new
ReleaseTime/ReleaseTimeSourcepair, resolved once inPublishedTimeResolver::releaseTime(), is the single source of truth for both the filter decision and its reporting — they cannot disagree. - The breakdown counts the filtered (dropped) versions — the recent ones inside the soak window — so the numbers stay signal, not noise.
- No change to the filter decision itself; this is pure observability on top of v1.9.0.
Full changelog: v1.9.0...v1.10.0
v1.9.0
Prefer Packagist's server-stamped published-time for soak time
The freshness filter now prefers Packagist's published-time over the time field when deciding whether a version has aged past the soak window.
Why it matters
time is the tag's committer timestamp — whoever publishes a release controls it and can backdate it, making a fresh malicious version look mature and slip straight past the soak window. published-time is stamped by the registry at publish and cannot be moved earlier, so it closes that bypass.
The field is dropped by Composer's ArrayLoader and never reaches the package objects, so the plugin reads it back from the repository metadata cache that Composer populates while building the solver pool — before the filter runs.
Behavior
- Preferred when present, with fallback to
timefor releases predating the field and for non-Packagist sources (Satis, VCS, artifact) that don't emit it. - Can only tighten the filter, never loosen it — every failure path falls back to the package's own release date.
- The integrity/drift layer is unchanged; it keys on the content-addressed source reference, not timestamps.
Verified end-to-end against live Packagist: the resolver matched the authoritative published-time for 753/753 cross-checked symfony/console versions, and the filter correctly drops a version whose published-time is fresh despite a backdated time.
Full changelog: v1.8.0...v1.9.0
v1.8.0
Ignore packages with abnormal dist plugins
Some Composer plugins install multiple dist archives under a single package@version. The clearest example is statamic/cms, which uses pixelfear/composer-dist-plugin to fetch both dist.tar.gz and dist-frontend.tar.gz — both presenting as statamic/cms@dist. Because the integrity model records one set of metadata per package@version, the second archive looked exactly like a drifted dist URL and hard-failed:
[Soak Time] Integrity metadata drift for statamic/cms@dist.
Field: dist URL
Recorded: …/v5.73.24/dist.tar.gz
Candidate: …/v5.73.24/dist-frontend.tar.gz
New: soak-time-integrity-ignore
There is no safe way to auto-support this — accepting a new dist URL under an already-pinned version on a package's say-so is precisely the altered-historical-release surface the plugin exists to close. Instead, you can now manually greenlight a package after verifying its installs:
{
"extra": {
"soak-time-integrity-ignore": ["statamic/cms"]
}
}Or as a comma-separated env var for a one-run override:
SOAK_TIME_INTEGRITY_IGNORE=statamic/cms composer updateA listed package is exempt from all integrity checks (drift, hash, and recording). Patterns follow the same rules as the whitelist — the vendor must be a literal, * is allowed only in the name half. A warning naming the ignored package(s) prints on every run so the weakened coverage stays visible, and the soak/freshness filter is unaffected.
See SECURITY_MODEL.md for why this is an explicit operator opt-in rather than automatic behaviour.
Full changelog: v1.7.0...v1.8.0
v1.7.0
v1.7.0 — Mutable dev branches & path-repository support
Adds first-class handling for two kinds of dependencies the immutable-pin model previously couldn't cope with: local path repositories and intentionally-mutable dev branches.
Added
- soak-time-dev-branches — opt-in mutable dev versions. Dev versions (dev-main, 1.x-dev, …) advance their git SHA every time the branch moves, which used to make composer update hard-fail once a pinned reference drifted. Declare such packages and the plugin re-pins them on each advance instead of blocking:
{
"extra": {
"soak-time-dev-branches": ["your-company/my-lib", "your-company/*"]
}
}
- Also settable per-run via SOAK_TIME_DEV_BRANCHES=your-company/my-lib composer update (comma-separated). Patterns follow the whitelist rules — literal vendor, * allowed only in the name half. Re-pinning is keyed solely on the git source reference (the unforgeable commit SHA): if the SHA is held fixed but the downloaded archive's sha256 differs, the plugin still hard-fails, since that's cache poisoning rather than a branch advance. Undeclared dev drift is blocked with an error that names the config key.
Fixed
- Local path repositories no longer break installs. A path-repo dependency has no dist archive hash and no source reference, so the integrity recorder threw Cannot pin integrity for
<pkg>@dev-masterand failed every install. Path packages are local code in the same trust domain as the root project, so they're now exempt from integrity pinning and drift checks. The exemption is gated on the dist URL actually being a local filesystem path, so a remote package can't dodge integrity checks by merely claiming dist.type: path.
Internal
- Folded the mutable-dev gate into a single mutableDevAdvanced() predicate so re-pinning can never be triggered for a non-dev or undeclared package. No behavior change.
- Test suite: 73 tests, 141 assertions.
Full Changelog: v1.6.0...v1.7.0
v1.6.0
What's new
- feat: allow wildcards in package names for easier whitelisting
Full Changelog: v1.5.0...v1.6.0
v1.5.0
What changed
- Add reinstall hint to fix stuck at update
- Allow php
8.1usage
Full Changelog: v1.4.2...v1.5.0
v1.4.2
What changed
- Fail closed when Composer installs from dist but the active plugin did not observe the archive download.
- Keep recovery explicit: rerun affected updates with --prefer-source so Composer verifies the pinned source reference.
- Save observed dist hashes immediately when the archive download is available.
- Keep Composer root packages, such as Laravel apps named laravel/laravel, in the solver pool.
v1.4.1
What's changed
- Fail closed when integrity lock entries are malformed or package metadata drifts.
- Keep integrity checks enabled when using SOAK_TIME_SKIP; skips now affect freshness filtering only.
- Pin source installs by source reference and source URL.
- Filter packages with missing release dates, while allowing Composer platform packages like php and ext-*.
- Add a short SECURITY_MODEL.md describing the threat model, trust boundary, and limits.
v1.4.0
v1.4.0
Soak time now also defends against altered historical releases (force-pushed tags). Every installed name@version is pinned by git SHA and dist sha256 in composer-integrity.lock; later drift hard-fails the run. Commit the lock alongside composer.lock. Opt out with extra.soak-time-integrity: false.
Upgrading from ≤ v1.3.0? A direct
composer updatewill fail because the old plugin'sSoakTimeConfigclass is still in PHP memory when the new code activates. Reinstall instead:composer global remove innobrain/soak-time && composer global require innobrain/soak-time # or, for project-local installs: composer remove --dev innobrain/soak-time && composer require --dev innobrain/soak-time
Full Changelog: v1.3.0...v1.4.0
v1.3.0
Full Changelog: v1.2.1...v1.3.0