Skip to content

Make stable versions immutable and allow soft-deletion / hiding of versions#1742

Merged
Seldaek merged 13 commits into
composer:mainfrom
Seldaek:immutable_releases
Jun 5, 2026
Merged

Make stable versions immutable and allow soft-deletion / hiding of versions#1742
Seldaek merged 13 commits into
composer:mainfrom
Seldaek:immutable_releases

Conversation

@Seldaek
Copy link
Copy Markdown
Member

@Seldaek Seldaek commented May 27, 2026

Why

Today the Updater silently rewrites a stable version row whenever upstream changes the source/dist reference (re-tags, force-pushed annotated tags, etc.). For a published v1.2.3 this is wrong — downstream lock files, mirrors, and security scanners all assume a tagged release is a frozen snapshot. And VersionRepository::remove() is the only way to pull a release: no recovery, no provenance, no way to distinguish maintainer cleanup from an admin takedown.

This PR locks stable versions, adds a reason-aware soft-delete, and a separate admin-only version hiding capability.

Stable-version immutability gate (Updater)

  • New rule: once a non-dev version row exists, the Updater never modifies its identity. Effective reference = source.reference if present, else dist.reference.
  • DELETE_BEFORE now only wipes dev versions; stable rows fall through to the immutability gate.
  • UPDATE_EQUAL_REFS renamed to UPDATE_SOURCE_DIST_URL. For stable rows, the URL rewrite only fires when both old and new refs are identical 40+ char hashes AND a driver-confirmed dist URL fetch verifies the new URL. Otherwise the version is skipped with a specific reason.

Soft-delete with reasons (new VersionDeletionReason enum)

  • AutoDeletedMissing — Updater soft-marks a row when upstream loses it; hard-purges after 1 day for dev versions only. Stable versions stay forever.
  • DeletedByMaintainer — pulled via the per-version UI delete.
  • DeletedByAdmin — pulled by an admin with an optional public reason.
  • Hidden — admin took the version down with no public trace (used by the spam-cascade and selective takedowns). Visible to maintainers/admins, hidden from everyone else.

V2 metadata + UI

  • V2Dumper filters soft-deleted versions out of both <name>.json and <name>~dev.json. Composer can no longer resolve a pulled version.
  • Package page lists soft-deleted versions grayed-out with a per-reason title (Deleted by maintainer on ..., Removed by admin on ...: <reason>, Hidden by admin on ...: <reason>, No longer found in upstream repository).
  • New "retag blocked" warning badge on stable rows where lastBlockedReference IS NOT NULL, linking to the new doc page.
  • Soft-deleted rows show a Recover button (maintainer-recoverable for maintainer/auto-missing reasons; admin-only recovery for versions removed or hidden by admins).

Audit + notification

  • New audit record types: VersionSoftDeleted, VersionRecovered, VersionReferenceChangeBlocked.
  • If a version ref change is detected, we email all maintainers with an update failure notification.
  • New static doc page at /about/version-immutability explaining the rule, what the maintainer sees, and the recommended remediation.

Out of scope

  • Malware-flagged versions: unchanged — they keep appearing in metadata, surfaced via the existing FilterListEntry UI.
  • Whole-package deletion (PackageManager::deletePackage / CleanSpamPackagesCommand) is a separate concept (whole row cascades) and untouched.

Copy link
Copy Markdown
Contributor

@glaubinix glaubinix left a comment

Choose a reason for hiding this comment

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

Not fully through yet. Still missing the second half of the updater changes + tests and JS code

Comment thread src/Entity/Version.php Outdated
Comment thread migrations/2026_05_version_immutability.sql Outdated
Comment thread src/Controller/PackageController.php
Comment thread src/Controller/UserController.php
Comment thread src/Package/Updater.php Outdated
Comment thread src/Package/Updater.php
SET updatedAt = :now, softDeletedAt = NULL, deletionReason = NULL, deletionReasonText = NULL
WHERE id IN (:ids)
AND softDeletedAt IS NOT NULL
AND (deletionReason IS NULL OR deletionReason = :autoReason)',
Copy link
Copy Markdown
Contributor

@glaubinix glaubinix May 27, 2026

Choose a reason for hiding this comment

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

Just curious when would deletionReason by NULL here? Is this for versions soft deleted before merging this PR? Wonder if we should mark them as AutoDeletedMissing via migration? Would take it there aren't that many versions flagged as softDelete right now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The migration file does set null ones to autodeleted missing actually, so once that ran through I guess we could simplify this condition yes.

And yeah there are 20K records with soft delete set right now, because some packages just never get updated for various reasons and then old branches are just left there dangling.

Comment thread src/Package/Updater.php
Comment thread src/Package/Updater.php
@Seldaek Seldaek force-pushed the immutable_releases branch from e34e37b to 767629f Compare June 4, 2026 09:07
@Seldaek Seldaek force-pushed the immutable_releases branch from e35b950 to 9dc3927 Compare June 4, 2026 13:31
@Seldaek
Copy link
Copy Markdown
Member Author

Seldaek commented Jun 4, 2026

Ok this is ready to go I think, will deploy it tomorrow morning.

@Seldaek Seldaek merged commit bfe30ae into composer:main Jun 5, 2026
3 checks passed
@Seldaek Seldaek deleted the immutable_releases branch June 5, 2026 06:53
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.

2 participants