Retention removes what a repository no longer needs: old and unused versions of packages, prereleases a release superseded, versions unlisted long ago, cold proxy caches, stale image tags and files, and whatever a size budget evicts. Each hosted or proxy repository can have one retention policy; group repositories hold nothing of their own and take none.
A policy is set on the repository's Retention screen in the web UI (Repositories → the repository → Retention), or through the admin API:
curl -u admin@example.com:<admin-token> -X PUT -H 'content-type: application/json' \
https://forge.example.com/admin/repositories/nuget-ci/retention -d '{"policy": {…}}'It is stored in the database with the repository, never in forge.toml.
Admin → Retention switches runs on or off for every repository and sets
how long the audit log is kept; [retention] in forge.toml only tunes the
worker (how often an instance looks for a due repository, 30 seconds by
default).
- The policy
- When a policy runs
- Package rules
- Proxy rules
- Formats: what "newest" and "prerelease" mean
- Raw file rules
- Image rules
- Preview, runs and the audit log
- Size budgets
Every policy has the same envelope, with rules of the repository's kind: package rules for NuGet, npm and the other package formats, file rules for raw repositories, image rules for Docker repositories (Helm charts included).
{
"version": 1,
"enabled": true,
"dry_run_only": false,
"schedule": "0 3 * * *",
"schedule_timezone": "Europe/Berlin",
"max_run_seconds": 3600,
"rules": [ … ]
}| Field | |
|---|---|
version |
Always 1. |
enabled |
Default true. A disabled policy is stored and never runs. |
dry_run_only |
Default false. Runs record in the audit log what they would remove, and remove nothing. |
min_run_interval_seconds |
Default 3600: the policy runs at most this often. Ignored when schedule is set. |
schedule |
A cron pattern of 5 fields: the policy runs at its times instead (When a policy runs). |
schedule_timezone |
The IANA time zone of schedule's times (Europe/Berlin, America/New_York); UTC without one. Only with a schedule. |
max_run_seconds |
How long a run may take, from 60 seconds to a week; without it a run takes as long as it needs. |
size_budget |
A logical size limit (Size budgets). |
rules |
The rules. May be empty when a size budget is set. |
Unknown fields are refused (422), so a misspelled one is never dropped silently, and an invalid value is a 400 that names its rule and field.
The retention worker runs on every instance; each repository is evaluated by one instance at a time.
-
At an interval (
min_run_interval_seconds, the default): a run at most every hour, or whatever the policy says. -
On a schedule (
schedule): at the times of a 5-field cron pattern,minute hour day-of-month month day-of-week, inschedule_timezone:scheduleRuns 0 3 * * *at 03:00 every day 30 1 * * 6at 01:30 on Saturdays 0 22 * * 1-5at 22:00 Monday to Friday 0 */6 * * *every six hours, on the hour @daily,@weeklyat midnight every day, every Sunday Lists (
1,15), ranges (1-5), steps (*/15,0-30/10) and names (MON-FRI,JAN) work as in Vixie cron. The times keep their local time across daylight-saving changes:0 3 * * *inEurope/Berlinruns at 03:00 Berlin time all year, which is 02:00 UTC in winter and 01:00 UTC in summer. A time that falls into the hour the clocks skip (30 2 * * *on the last Sunday of March in Berlin) runs once, right after the change, at 03:00; a time in the hour they repeat (30 2 * * *on the last Sunday of October) runs once, at its first occurrence.A policy saved with a schedule waits for its next time: it does not run as soon as it is saved. The worker never starts a run less than a minute after the previous one started. A time missed while no worker ran (every instance down, or runs switched off in Admin → Retention) is made up once, as soon as the worker runs again; set
max_run_secondsto keep such a run short. -
Run now (
POST /admin/repositories/<name>/retention/run) runs the stored policy at the worker's next poll, whatever its schedule or interval says. The run after it is at the schedule's next time again.
The Retention screen shows the schedule and the next run, and a preview of a draft with a schedule lists its next five run times.
max_run_seconds ends a run at the end of a maintenance window. The
run applies its decisions in batches (up to 100 versions of one package, or
files of one directory, or tags and manifests of one image); once the time
is up, it stops before the next batch. The run summary counts what it left
as deferred. The next run evaluates the repository again and goes on with
what is left, so a cleanup larger than one window spreads over several runs.
The first batch of a run always goes, so a run makes progress even when
evaluating the repository alone takes longer than the window.
A package rule selects versions and unlists or deletes them:
{
"id": "ci-prereleases",
"action": "hard_delete",
"release_type": "prereleases",
"package_name_prefix": "acme.",
"selector": "ci-feeds",
"version_pattern": "*-ci.*",
"exclude_version_pattern": "*-ci.0",
"criteria": {
"keep_last_n": 3,
"prerelease_superseded_for_days": 14
}
}| Field | |
|---|---|
id |
Recorded on every audit row the rule produces; unique in the policy. size_budget is reserved. |
action |
unlist (the version stays downloadable by its exact version) or hard_delete (the version and its files are gone). |
scope |
hosted (the default) on hosted repositories, proxy_cached on proxies (Proxy rules). |
release_type, package_name_prefix, selector, version_pattern, exclude_version_pattern |
Filters: the versions the rule looks at. |
criteria |
Protections and triggers. |
How rules combine:
- A version goes when any rule selects it. A rule selects a version when its filters admit it and every trigger it sets holds.
- What any rule protects is never unlisted or deleted, by any rule and whatever trigger selects it.
- When two rules select a version, deleting wins over unlisting.
- A version a package tag names (npm's
latest,next, …) is never touched. - Unlisting a version that is unlisted already does nothing, and is not audited again.
All optional; a rule without filters looks at every version.
| Filter | Admits |
|---|---|
release_type |
any (default), releases, prereleases or snapshots: the class the repository's format gives the version (Formats). For NuGet and npm a prerelease has a label after a dash (2.0.0-beta.1). |
package_name_prefix |
Packages whose id starts with it, ignoring case (acme.). |
selector |
Packages a content selector (Admin → Content selectors) admits, by its name: its include and exclude globs on the package id, as a grant scoped by it reads them. The selector must exist when the policy is saved; one that is deleted since fails the policy's runs (nothing is removed), and a selector a policy names cannot be deleted until no rule names it. |
version_pattern |
Versions this glob matches, ignoring case: * any characters, ? one, [0-9] a set, {*-ci.*,*-pr.*} either. The glob covers the whole normalized version. |
exclude_version_pattern |
Never the versions this glob matches. |
| Protection | Keeps |
|---|---|
keep_last_n |
The newest N listed versions of each package, among the versions the rule's filters admit. Unlisted versions neither count nor are kept by it, so after a broken release is unlisted, the N newest installable versions are still kept. |
keep_n_most_downloaded |
The N versions of each package downloaded last, listed or not; never-downloaded versions rank last, newest first. |
A protection counts within the rule's own filters: a releases rule with
keep_last_n: 3 keeps the three newest releases, never prereleases. A rule
needs a trigger; a rule with protections only is refused.
| Trigger | Selects a version |
|---|---|
older_than_days |
published at least N days ago. |
not_downloaded_for_days |
not downloaded for N days; a version never downloaded counts from its publication. |
prerelease_superseded_for_days |
that is a prerelease (or a snapshot) once its package has had, for N days, a listed release that comes after it: for SemVer, a release of the same or a higher version, so 2.3.0-ci.417 goes 14 days after 2.3.0 (or 2.4.0) was published. The clock starts at the first such release's publication, or at the prerelease's own when it was published later (a lagging CI bump, a maintenance line's 1.9.1-rc.1 after 2.0.0): a prerelease always has its N days. A release is never selected. |
unlisted_for_days |
that has been unlisted for N days (a listed version never). Only on hard_delete rules: the second step of "unlist, then delete later". |
max_versions_per_package |
that is not among the N newest versions of its package (listed or not) the rule's filters admit. A pure count: no age needed. |
Every count and day value is at least 1. The audit log and the preview give
each decision its reason: the triggers that held, the release that
superseded a prerelease (superseded_by), and a version's place past the
cap (rank).
When a version was unlisted. Every version records it
(component.unlisted_at), whichever way it was unlisted: by a client
(NuGet's delete, npm), by a retention rule, pushed or imported unlisted.
Listing it again clears it. Versions unlisted before Forge recorded it take
the time of their last change, which is never earlier than their unlisting.
CI prereleases go 14 days after their release, and no package keeps more than 50 prereleases at all:
{
"version": 1,
"schedule": "0 2 * * *",
"schedule_timezone": "Europe/Berlin",
"rules": [
{ "id": "superseded", "action": "hard_delete", "release_type": "prereleases",
"criteria": { "prerelease_superseded_for_days": 14 } },
{ "id": "too-many", "action": "hard_delete", "release_type": "prereleases",
"criteria": { "max_versions_per_package": 50 } }
]
}Pull-request builds go after 7 days, except the newest of each package:
{ "id": "pr-builds", "action": "hard_delete", "version_pattern": "*-pr.*",
"criteria": { "older_than_days": 7, "keep_last_n": 1 } }Unlist, then delete later: releases unused for a year are unlisted, and deleted 90 days after that, unless someone relisted them meanwhile; the three newest releases of each package stay:
{
"version": 1,
"rules": [
{ "id": "unlist-cold", "action": "unlist", "release_type": "releases",
"criteria": { "not_downloaded_for_days": 365, "keep_last_n": 3 } },
{ "id": "delete-unlisted", "action": "hard_delete",
"criteria": { "unlisted_for_days": 90 } }
]
}Only the packages of the ci-feeds content selector, outside working hours,
an hour at most per night:
{
"version": 1,
"schedule": "0 1 * * 1-5",
"schedule_timezone": "America/New_York",
"max_run_seconds": 3600,
"rules": [
{ "id": "ci", "action": "hard_delete", "selector": "ci-feeds",
"criteria": { "older_than_days": 30, "keep_last_n": 10 } }
]
}A proxy repository's rules say "scope": "proxy_cached" and evict cached
versions: their bytes are deleted, the version stays listed, and its next
pull fetches it from the upstream again. Proxy versions are never unlisted.
An apt proxy is the exception: retention does not evict its cached files
yet, whatever its policy says (apt.md).
| Trigger | Evicts a cached version |
|---|---|
not_pulled_for_days |
not pulled for N days; one never pulled counts from when the proxy first saw it. |
max_versions_per_package |
not among the N newest cached versions of its package the rule's filters admit, however recently pulled. |
The filters and protections work as on hosted rules, over each package's
cached versions: keep_last_n keeps the newest N cached versions the
upstream lists, keep_n_most_downloaded the N pulled last. The hosted
triggers (older_than_days, not_downloaded_for_days,
prerelease_superseded_for_days, unlisted_for_days) are refused here.
{ "id": "evict-cold", "action": "hard_delete", "scope": "proxy_cached",
"criteria": { "not_pulled_for_days": 30, "keep_last_n": 5, "keep_n_most_downloaded": 5 } }keeps the 5 newest and the 5 most recently pulled cached versions of each package and evicts any other one nobody pulled for 30 days.
Versions are ordered by the repository's format, never as text: NuGet orders
1.10.0 after 1.9.0 and a prerelease before its release. Two retention
hooks of the format contract
(format crates) say the rest:
- The version class: release, prerelease or snapshot.
release_typefilters on it, andprerelease_superseded_for_daysdeletes what is not a release. - The partition keep-N counts within (
keep_last_n,keep_n_most_downloaded,max_versions_per_package): the whole package unless the format splits it.
NuGet and npm use the generic rules: a version with a - is a prerelease,
any other a release, and a package is one partition. Helm charts are stored
in Docker repositories and follow the image rules. apt
(apt.md) has no prereleases (a - starts the Debian revision, so
every version is a release), orders versions as dpkg does, and counts per
package and architecture: its package ids are <name>:<architecture>.
No format Forge has today makes snapshots, so release_type: snapshots
matches nothing yet. Maven would bring its own rules: its -SNAPSHOT builds are
snapshots counted per snapshot version (the newest N builds of each), its
releases per groupId:artifactId, and prerelease_superseded_for_days
removes a version's snapshots once its release exists.
Raw repositories have file rules: path_prefix (only files under it),
keep_newest_per_directory (the N most recently deployed files of each
directory stay), and the triggers older_than_days and
not_downloaded_for_days. See Raw file repositories.
{ "id": "nightlies", "path_prefix": "nightly/", "keep_newest_per_directory": 5,
"older_than_days": 14 }Docker repositories have image rules: per image (image_prefix), tag rules
with keep_last_n_tags, protected_tags, tag_older_than_days and
tag_not_pulled_for_days, and delete_untagged_after_days for manifests
left without a tag. A tag rule looks at the tags tag_pattern matches and
exclude_tag_pattern does not (all tags without either); protected_tags
takes names and globs. See Docker / OCI registry.
{ "id": "pr-tags", "tag_pattern": "pr-*", "tag_older_than_days": 7,
"protected_tags": ["latest", "release-*"] }deletes pr-* tags a week after their push, whatever else the image has.
POST /admin/repositories/<name>/retention/previewevaluates the stored policy, or a draft sent as{"policy": {…}}(checked like aPUTof it, never stored, evaluated even when disabled), and lists what a run would do now, with the rule and the reason of each decision. It changes nothing. A draft with a schedule also lists its next runs (next_runs).- A run re-checks each batch before it applies it: it stops when the policy changed or it lost its lease (the next run goes on under the policy as it is then), and skips what the repository's current state no longer warrants (a version downloaded, relisted or protected since).
GET /admin/repositories/<name>/retentionreturns the policy and the run state: the next run, the last run's times, error and summary (evaluated,unlisted,deleted,skipped,deferred, the blobs queued for deletion, and a size budget's sizes).GET …/retention/auditlists every decision of every run, dry runs included, newest first, filtered byrun_idand paged withbefore.
Deleted content goes through the blob outbox: a blob is deleted from the object store only once nothing references it any more.
Hosted and proxy package repositories, raw file repositories, and Docker
repositories can set a logical size budget in their retention policy. Add
size_budget to the policy envelope:
{
"version": 1,
"size_budget": {
"max_bytes": 536870912000,
"target_bytes": 483183820800
},
"rules": []
}max_bytes must be greater than zero. If target_bytes is omitted, it
defaults to 90% of max_bytes; it cannot be greater than max_bytes. The
budget may be the policy's only content, so rules can be empty when a budget
is set. Without a budget, at least one rule is required. enabled and
dry_run_only apply to the budget as they do to retention rules.
The budget measures logical repository size, not physical object-store usage: package and raw-file size is the sum of their asset/file sizes. Docker size is the sum of distinct blobs linked by the repository's manifests. A blob shared between repositories counts toward each repository's logical size; evicting it from one repository may not free physical storage while another repository still references it.
Rules are applied first. If content still exceeds max_bytes, the budget
evicts unprotected content in least-recently-used order until it reaches
target_bytes or has no eligible content left:
- Packages: versions are ordered by last download, or publication/first-seen time if never downloaded. Hosted repositories retain each package's newest listed version; proxy versions are evicted from the cache and can be fetched again.
- Raw files: ordered by the later of their last download and their deployment, so a file deployed again counts as used then.
- Docker: untagged manifests go first, ordered by last pull, or push time if
never pulled. Then tagged manifests, ordered by the later of their last pull
and their newest tag's push (as
tag_not_pulled_for_daysjudges a tag): the budget deletes all of a manifest's tags together, and the manifest with them. It never deletes a tag whose manifest would stay, since that frees nothing: not an alias of a manifest a protected tag also names, and not the tags of a manifest that holds its image's last tags. An image always keeps a tag. Freed size counts only blobs no remaining manifest in the repository links.
The protections of ordinary rules also apply to budget eviction, including keep-N rules, protected tags, and Docker index children and referrers. If protected content alone is larger than the target, eligible content is still removed and the run reports that it could not reach the target.
Retention preview reports budget decisions with reason size_budget and
includes the size before and projected size after. The retention API reports
the repository's current logical size, and the run summary records the
before/after sizes, target, and whether it was reached. The Retention
screen edits the maximum and the target in GB or GiB, displays the current size
against the configured maximum, and warns when the last run could not reach
its target.