Skip to content

Upgrading ​

Upgrade by replacing the binary or image; the database schema is migrated forward automatically at startup.

Checklist:

  1. Read the release notes on the GitHub releases page (generated by GoReleaser from the git history, grouped into Features, Bug fixes, CI and build, Other).
  2. Back up the database and blob store (backup) and make sure the secrets are stored safely.
  3. Deploy the new version (Docker Compose, Helm, binary).
  4. Check GET /health: the JSON version field shows the running version and status should be healthy.

Versions and image tags ​

  • The VERSION file at the repository root is the release version. Releases are cut from tags vX.Y.Z; the release workflow fails unless VERSION equals the tag without the v. A tag containing a - (for example v1.0.0-rc.1) is a pre-release.
  • Images are published to ghcr.io/openartifact/openartifact for linux/amd64 and linux/arm64 with tags X.Y.Z (no v) and X.Y; latest is only moved by tags without a pre-release suffix. Pin an exact X.Y.Z in production.
  • Release archives (openartifact_<version>_<os>_<arch>.tar.gz, oa_...) and SHA256SUMS are attached to the GitHub release; see installation and signing to verify them.
  • A binary built without -ldflags reports version dev. The compose file builds from the checkout and takes the version, commit and build time from OPENARTIFACT_VERSION, OPENARTIFACT_COMMIT and OPENARTIFACT_BUILD_TIME (defaults dev, unknown, unknown); make docker-up fills them from the VERSION file and git. openartifact-server -version prints what a binary was stamped with.

Database migrations ​

  • Migrations are SQL files embedded in the binary (migrations/) and applied in file-name order. Applied versions are recorded in the schema_migrations table (version, applied_at, checksum, the SHA-256 of the file).
  • They run automatically at every start unless MIGRATE_ON_START=false, under a PostgreSQL advisory lock, so several replicas can start at once and only one applies each migration. Each file runs in its own transaction; files named *.no_tx.sql run without one, one statement at a time (needed for CREATE INDEX CONCURRENTLY, which cannot run in a transaction).
  • Forward-only. There are no down migrations. An applied file must never change: if the checksum of an applied migration differs from the one in the binary, the migrator aborts with a checksum drift error instead of continuing.
  • A failed migration is rolled back (transactional files) and the server logs auto-migrate failed and exits with status 1.
  • Running a new binary against an old database applies the pending migrations; there is no dry-run mode.

Migrating separately from the rollout ​

Set MIGRATE_ON_START=false (the exact lowercase string; other values keep migrations on) and run the migrations as a one-off step before starting the new version, with the same database settings:

sh
./openartifact -migrate                     # binary: applies migrations and exits
docker compose -f docker/docker-compose.yml run --rm openartifact ./openartifact -migrate

With MIGRATE_ON_START=false the server logs automatic migrations skipped at start and does not check the schema version, so a new binary against an unmigrated database may fail at query time. make migrate does the same from a source checkout (go run ./cmd/server -migrate).

Docker Compose ​

From the checkout of the new version:

sh
git fetch --tags && git checkout vX.Y.Z
make docker-up    # same as docker compose -f docker/docker-compose.yml --env-file docker/.env up -d --build, plus build metadata

This rebuilds the openartifact image and recreates the container; the named volumes (postgres_data, artifacts_data, backups_data) are kept. docker compose down -v deletes them, do not use it for an upgrade. Migrations run when the new container starts. To run a released image instead, point the service at ghcr.io/openartifact/openartifact:<version> and use docker compose pull && docker compose up -d. The compose file pins the PostgreSQL major version (postgres:18-alpine); it is not changed by an OpenArtifact upgrade.

Helm ​

The chart is versioned separately from the application (the chart version and appVersion differ). The image tag defaults to the chart's appVersion; override with image.tag.

sh
helm upgrade openartifact oci://ghcr.io/openartifact/charts/openartifact \
  --namespace openartifact -f my-values.yaml
# classic repository: helm repo update && helm upgrade openartifact openartifact/openartifact -n openartifact -f my-values.yaml
  • Pods apply migrations at startup (config.migrateOnStart, default true) under the advisory lock. During a rolling update, old pods keep running against the already-migrated schema until they are replaced; nothing in the project guarantees that an older version works with a newer schema.
  • With a single ReadWriteOnce volume the Deployment uses Recreate, so an upgrade has a short downtime. Object storage or ReadWriteMany allows rolling updates.
  • The generated Secret (AUTH_HMAC_KEY, OPENARTIFACT_SECRET_KEY) is reused on upgrade through Helm's lookup. That does not work under helm template: with Argo CD or similar, create the Secret yourself and set secrets.existingSecret. Option reference and details: chart README.

Binary ​

Stop the old process, replace the openartifact binary (and resetpw) with the new release, then start it with the same configuration. Run ./openartifact verify openartifact.yaml first to catch configuration errors; compare your file against the new openartifact.example.yaml and root .env.example for added keys (configuration).

Secrets to preserve ​

Keep these identical across upgrades, restores and replicas:

SettingIf lost or changed
OPENARTIFACT_SECRET_KEYValues stored encrypted (enc:v1:...: webhook secrets, upstream passwords, SSO client secrets, AI keys, storage credentials) can no longer be decrypted. Removing the key entirely makes reading them fail; setting it for the first time leaves existing plaintext values readable
AUTH_HMAC_KEYSigns OIDC/SAML login state. If unset, a random key is generated once and persisted in system_settings, shared by replicas. A configured value wins. Sessions and API tokens are not affected
OPENARTIFACT_SAML_SP_CERT / _KEYIf unset, a self-signed keypair is generated once and persisted encrypted in system_settings (unencrypted without OPENARTIFACT_SECRET_KEY), shared by replicas. Regenerating it (lost database) means re-registering the SP certificate at the IdP
BASE_URLSAML entity ID and ACS URL, and links in generated indexes, are derived from it; changing it breaks existing IdP registrations

Docker Compose only passes AUTH_HMAC_KEY through; see installation for adding the others. The docker/.env file and any compose override belong in your backup.

Version notes ​

0.13.2 ​

No migrations.

  • New optional server.shutdownDelay (SERVER_SHUTDOWN_DELAY, seconds, default 0) fixes the few failed requests during a Kubernetes rolling update. On SIGTERM/SIGINT the server now flips /ready to 503 first (/health stays 200), turns HTTP keep-alives off and keeps serving for the delay so the Service drops the pod from its endpoints before the listener closes; a second signal skips the delay. With the default 0 nothing changes. If you set it, keep terminationGracePeriodSeconds at or above shutdownDelay + shutdownTimeout plus about 20 s. See graceful shutdown.
  • A new metric counts OTLP log records dropped on a full export queue: openartifact_telemetry_log_records_dropped_total, by instrumentation scope. When the log export queue is full the newest record is now dropped (and counted) instead of silently overwriting the oldest. Only relevant if you export logs or audit events over OTLP (telemetry.otlp.logs / telemetry.otlp.audit); no action needed.

0.13.1 ​

No migrations and no configuration changes; a rolling upgrade from 0.13.0 works. Maintenance release:

  • The web UI offers package delete for apk and terraform repositories and lets you activate GPG keys on pacman and terraform repositories; the server already supported both. The vulnerability ecosystem dropdown follows the format registry order.
  • Package formats now come from one registry (internal/repotype), and the web format lists are generated from it. There is no behaviour change except that the only hosted ... repositories are signed error message lists the signing formats alphabetically.
  • Test fixes for two flaky tests (federation conflict counters, an unknown-blob fixture); no product change.

0.13.0 ​

Migrations 087 to 090, all rolling-safe: a rolling upgrade from 0.12.x works (0.12.x replicas keep serving against the migrated schema). Upgrading from 0.10.x or older still needs the 0.11/0.12 steps below first. 087 deletes the unused seeded local storage backend row (a single guarded DELETE, no schema change). 088 (Terraform), 089 (apk) and 090 (pacman) each widen the chk_repo_format check with NOT VALID in a short transaction (10 s lock_timeout), validate it in a separate *_validate.no_tx file without blocking writes, and create new tables; no existing table is rewritten. Replicas still on 0.12.x do not know the new formats or tables until they are replaced, so create the first Terraform, apk or pacman repository only after the rollout has finished.

Highlights: new formats (Terraform and OpenTofu registry, Alpine apk, Arch Linux pacman), proxy and group repositories for RubyGems, Conda, sbt, Ivy and Conan, the OpenAPI document moving to 3.1.1 (code generators must support 3.1), the new workers.proxyUpstreamStallTimeout setting (OPENARTIFACT_PROXY_UPSTREAM_STALL_TIMEOUT) with HTTP/2 upstream health checks, and /health reporting the real storage status.

  • The OpenAPI document is now OpenAPI 3.1.1 (it was 3.0.3). nullable: true became type: [T, "null"] (or anyOf with a {"type": "null"} member for a nullable $ref), schema example became examples, exclusiveMinimum is numeric, file bodies use contentMediaType instead of format: binary, and info.license carries an SPDX identifier instead of a url. Clients that generate code from /api/v1/openapi.json or /api/v1/openapi.yaml need a generator that supports OpenAPI 3.1. The REST API itself is unchanged.
  • RubyGems repositories can be proxies and groups. A proxy mirrors a gem server (default https://rubygems.org) through the compact index and caches .gem files after checking them against the info line's SHA-256; a group merges hosted and proxy members, hosted members winning the names they hold (see merge_proxy_versions). Hosted RubyGems repositories now also serve the compact index (/versions, /names, /info/<gem>), so bundler uses it instead of the legacy spec indexes. Limits: generated (hosted and group) index files ignore Range, hosted info lines omit the ruby: and rubygems: requirements, a group does not serve specs.4.8.gz (gem search -r needs a single member), and a proxy refetches the whole versions file (about 25 MB for rubygems.org) when its TTL expires.
  • New format: Arch Linux (pacman), hosted. format: pacman repositories serve HOST/repos/REPO/os/<arch>/ to pacman: packages are uploaded with the new endpoint PUT /repos/{name}/os/{arch}/{file} (.pkg.tar.zst/.xz/.gz and an optional .sig), the .db/.files databases are generated and signed with the repository's GPG key. repo_type: proxy is supported too (a path mirror of an Arch mirror root, default upstream https://geo.mirror.pkgbuild.com); group repositories are described in the next entry. Migration 090 (pacman_packages) is additive and rolling-safe: it creates the pacman_packages table and widens chk_repo_format to accept pacman; older instances keep running against the migrated schema but do not know the format. The trash gains the kind pacman. pacman is a GPG-key-managed format: hosted repositories of this format appear in the GPG key assignment next to deb and rpm ones, and packages uploaded without a .sig are signed by the server with that key. See Arch Linux (pacman).
  • pacman group repositories. repo_type: group is now accepted for the pacman format: a group merges the databases of hosted pacman repositories and of pacman proxies over Arch mirror sections (upstream_url such as https://geo.mirror.pkgbuild.com/extra) per architecture, signs them with the server-wide key and serves packages from the member that owns them. Clients use Server = HOST/repos/GROUP/os/$arch; see pacman groups. Hosted pacman repositories now also notify groups of changes.
  • Conda proxy and group repositories. A conda repository can now be a proxy of a channel (upstream_url such as https://conda.anaconda.org/conda-forge, required) or a group of hosted and proxy members. The proxy serves repodata.json/.zst/.bz2, current_repodata and channeldata.json byte for byte and only serves packages the upstream repodata lists, verified against its digest; repodata.jlap and sharded repodata answer 404. A group merges repodata.json and current_repodata.json (plain and .zst) with hosted members winning a package name. See conda. Migration none; two new queries (ListCondaPackageNames, HasCondaPackageName) read existing tables.
  • New format: Alpine (apk) repositories (migration 089). A hosted repository of format apk serves .apk packages and a signed APKINDEX.tar.gz per <branch>/<repo>/<arch> directory to apk on Alpine 3.6 or later. Clients need the repository's public key in /etc/apk/keys; see Alpine (apk) for setup, upload and key handling.
    • Migration 089 widens the chk_repo_format check with apk (added NOT VALID in the short transaction; 089_apk_validate.no_tx then validates the existing rows without blocking writes) and creates the apk_packages and apk_signing_keys tables. No existing table is rewritten.
    • New endpoints. POST /api/v1/repositories/{name}/apks (upload), and GET/POST /api/v1/repositories/{name}/apk-key (read, rotate or import the index signing key; global manage permission). Both are listed in the REST API reference.
    • Set OPENARTIFACT_SECRET_KEY. The RSA signing key is sealed with it. Without it the index is served unsigned and clients need --allow-untrusted.
    • Not in backups. The signing key is not part of backups or declarative export: after a restore or rebuild a new key is generated and clients must install its public key (backup and rebuild).
    • Downgrading to a version that does not know the format: delete every apk repository first and purge it from the trash; the widened constraint and the two empty tables stay in the database and are harmless.
  • Alpine (apk) repositories can be proxies. format: apk accepts repo_type: proxy with an upstream_url that is the mirror root (no default; for example https://dl-cdn.alpinelinux.org/alpine). APKINDEX.tar.gz is passed through unmodified with the upstream signature and cached for the repository TTL; a .apk is cached for good once its checksum matches the upstream index, a package the index does not list answers 404 and one that does not match 502. Clients keep using the Alpine keys they already trust and install no key from OpenArtifact: see proxy repositories. No migration.
  • Alpine (apk) repositories can be groups. format: apk accepts repo_type: group over hosted and apk proxy members. The group publishes an APKINDEX.tar.gz per <branch>/<repo>/<arch> directory, merged from its members in priority order (hosted members win a package name over proxies unless merge_proxy_versions is true) and signed with the group's own key, which clients install from HOST/repos/GROUP/public.key; package files come from the member whose entry the index lists. It needs OPENARTIFACT_SECRET_KEY like hosted apk repositories. See group repositories. No migration.
  • Ivy and sbt repositories can be proxies and groups. format: ivy and format: sbt now accept repo_type: proxy (a path mirror of upstream_url, no default; for example https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases) and repo_type: group. A release file is checked against the .sha256/.sha1/.md5 the upstream publishes before it is cached; groups resolve first-hit and apply the hosted-wins rule per organisation/group and module. No migration; existing hosted repositories are unchanged. See Ivy and sbt.
  • Conan proxy and group repositories. repo_type proxy and group are now accepted for the conan format (default upstream https://center2.conan.io). A proxy caches a Conan remote that serves the Conan 2 API with revisions (recipe and package revision lists, file lists and files; latest and the lists for the cache TTL, files for good) and serves Conan 1 clients from it; searches pass through. Upstream credentials (a user and password, or only a token as the password) are stored encrypted. A group merges revisions and searches across its members, and hosted members win a recipe name over proxy members unless merge_proxy_versions is true. Proxy and group repositories are read-only (conan upload answers 405). ping of a proxy or group advertises revisions only. No migration and no action required. See Conan proxies.
  • /health reports the real storage status. The public /health (and the status of /api/v1/system/info, storage_status of /api/v1/admin/system/info) always said ok for storage, or only probed S3-family stores, because the metrics wrapper around the store did not expose a health check. Every wrapping layer (metrics, tiers, replication, presigned redirects) now forwards the check to the primary backend, so a lost filesystem directory, Azure container or OCI registry shows as degraded too ("status":"error", "message":"unavailable", no detail; the cause is logged). The probe is cached for 10 seconds and bounded to 3 seconds, so /health stays cheap. A filesystem store that is read-only or full now reports error on /health; /ready is unchanged (S3-family stores only), so such a pod is not taken out of rotation. See monitoring.
    • replication is a reserved storage backend name, like primary: /api/v1/admin/storage/replication is the replication status endpoint and takes precedence over /{name}. Creating or renaming a record to replication (or primary) answers 400. No migration touches existing rows: a record that already has one of these names stays in the list and keeps working for PATCH and DELETE by name (and POST .../{name}/test for replication), but GET /api/v1/admin/storage/replication (or primary) answers the static endpoint. To get it back under a normal name, rename it through the API (the CLI and the web page keep the name read-only): curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name": "archive"}' https://<server>/api/v1/admin/storage/replication. Nothing else refers to a record by name (tiers use its id), so the rename is safe.
    • The storage backends page counts the primary store in the stat tiles, and Test all tests it with the records.
  • The storage backends page shows the real primary store. Every install used to carry a seeded record local (filesystem, ./storage, flagged default) that never described the store the server runs on (STORAGE_*, for example an S3 bucket): the page listed it as unreachable with 0 B and the real store was invisible. Migration 087 deletes that record when it is still exactly what the installer wrote (name local, kind filesystem, config {"path": "./storage"}) and no tier, tier move, queued deletion or blob refers to it; a record you renamed, re-pointed or used is kept. It is rolling-safe (0.12 pods only list and test records; the is_default column and its index stay until a later release drops them). GET /api/v1/admin/storage gains a read-only primary object (kind, non-secret location, live status, usage), GET /api/v1/admin/storage/primary returns it and POST /api/v1/admin/storage/primary/test probes it; PATCH and DELETE of primary answer 405. See the primary store in the admin API.
    • is_default is removed from the storage backend API, the UI and oa storage (--default and the DEFAULT column are gone). Nothing ever read it: the default store is the one configured with STORAGE_*. A request that still sends is_default is accepted and the field ignored. A kept record that was flagged default can now be deleted like any other. The name primary is reserved for the configured store; creating or renaming a record to it answers 400.
    • FilesystemStore gains a HealthCheck (the directory exists and a temporary file can be created and removed in it), used by the primary status above.
  • The server refuses to start with a signing key that cannot sign. A configured gpg.key_path that held a public-only key, a passphrase-protected key without gpg.passphrase, or a revoked or expired key used to start and then panic on the first signed InRelease request. Loading it now fails at startup with an error naming the cause.
  • A recovered panic answers with the standard JSON error body. A handler panic used to produce a 500 with the plain-text body Internal Server Error; it now answers 500 with {"error":"internal server error","code":"internal_error"} and Content-Type: application/json, like other errors. The panic text is still only logged.
  • OpenTelemetry has per-signal switches under telemetry.otlp: traces (OTEL_TRACES_EXPORTER), metrics (OTEL_METRICS_EXPORTER, unchanged), logs (OTEL_LOGS_EXPORTER) and audit (OPENARTIFACT_OTLP_AUDIT). enabled stays the master switch and traces defaults to true, so an existing enabled: true (or OPENARTIFACT_OTLP_ENABLED=true) keeps tracing exactly as before; set OTEL_TRACES_EXPORTER=none to export metrics, logs or audit without traces, which also removes the HTTP tracing middleware. enabled: true with every signal off is accepted and logs one warning at startup. logs and audit start an OTLP log provider on the same endpoint (no records yet). The compose file has an opt-in otel-collector profile with a baseline otelcol-contrib configuration and passes the OpenTelemetry variables from docker/.env to the server. See monitoring.
  • New format: Terraform and OpenTofu registry (migration 088). A hosted repository of format terraform is a private registry for modules and providers: terraform init and tofu init install from it with a credentials block, and the server signs provider checksums with the repository's active GPG key. See Terraform and OpenTofu for the requirements (HTTPS, a registry host name with a dot, BASE_URL set to the public HTTPS URL) and the publish API. Nothing changes for existing repositories.
    • Migration 088 widens the chk_repo_format check with terraform (added NOT VALID in the short transaction, so no table scan runs under the lock; 088_terraform_validate.no_tx then validates the existing rows without blocking writes) and creates the terraform_modules and terraform_providers tables with their indexes and triggers. No existing table is rewritten, so it is rolling-safe: replicas still on the previous version keep serving every other format against the migrated schema, and do not know the new tables or the format until they are replaced. Create the first terraform repository only after every replica runs the new version.
    • New public routes. GET /.well-known/terraform.json (service discovery) and GET/HEAD /terraform/dl/<ticket>/<file> (15-minute download URLs; the clients send no credentials there) are answered without authentication. If a reverse proxy only forwards /repos/, /api/ and the like, forward these two paths as well. Replicas must share AUTH_HMAC_KEY, since tickets are signed with a key derived from it.
    • Downgrading to a version that does not know the format: delete every terraform repository first and purge it from the trash. The older binary does not know the tables or the format, so such repositories would show up without content and every Terraform client would fail against them; the widened constraint and the two empty tables stay in the database and are harmless. Restoring the pre-upgrade backup works as for any other migration (above).
  • Terraform and OpenTofu proxy and group repositories. repo_type proxy and group are now accepted for the terraform format (no migration). A proxy mirrors registry.terraform.io (the default upstream_url) or registry.opentofu.org; its name must be the upstream namespace (hashicorp serves HOST/hashicorp/random). Provider and module archives that the upstream hosts elsewhere are fetched through the server on first use and served through the existing 15-minute download tickets; provider zips are checked against the upstream shasum and SHA256SUMS before they are stored and keep the upstream's signing keys. Modules are fetched from github.com sources (git::https://github.com/ORG/REPO?ref=..., the form the public registries use, mapped to a codeload tarball) and plain archive URLs, both with //subdir selectors; they are repacked without the wrapper directory (symlinks, hard links, traversal and oversized archives are refused with 502) and pinned to the digest of the first copy, so a moving branch or tag answers 502 after the cache entry expires. Other go-getter sources (git:: on other hosts, s3::, hg::, ...) answer 501. A group merges hosted and proxy members, hosted members winning a module name or provider type they hold. See Terraform and OpenTofu. The download tickets of a proxy repository need the same AUTH_HMAC_KEY on every replica, as for hosted repositories.

0.12.0 ​

Migration 086. It drops the stored search_tsv columns (deb_packages, rpm_packages, maven_artifacts, npm_versions) and rpm_repos.signing_key_id / signing_key_fingerprint, with their indexes, in a transaction with a 10 s lock_timeout. A rolling upgrade requires coming from 0.11.x: 0.10.x pods still select these columns. Upgrading directly from 0.10.x must stop all pods first, or go through 0.11.

  • Smaller binaries. The linux/amd64 server goes from 55.0 to 44.0 MB (the container image shrinks by the same amount; compressed layer: 19.2 to 17.0 MB), resetpw from 19.8 to 10.1 MB, oa is unchanged. Two causes: the release builds use -tags grpcnotrace, which drops gRPC's net/trace debug pages (EnableTracing was never set, so nothing is served or configurable differently), and the web UI is embedded gzipped once instead of plain. There is no configuration or behaviour change, including OTLP protocol: grpc and http. If you build the server from source, add -tags grpcnotrace to get the same size (the binary works without it); make build does. See binary size.
  • Docker groups serve proxied images when a hosted member is empty. A docker group whose hosted member held no image of that name answered MANIFEST_UNKNOWN for the proxy members' images unless merge_proxy_versions was true, because a hosted docker repository answered tags/list of any image with 200 and an empty list, which the group read as "this hosted member holds the image". GET /v2/<repo>/<image>/tags/list of a hosted docker repository now answers 404 NAME_UNKNOWN while the image has no manifest (an image with manifests but no tag still lists as empty), as other registries do; groups fall through to their proxy members for such images. Images that a hosted member does hold stay hosted-only unless merge_proxy_versions is set. Scripts that read the tag list of an image that was never pushed must expect 404 instead of {"tags":[]}; docker, skopeo and crane are unaffected. No action required.
  • A request to /repos/REPO/upload of a hosted Debian or RPM repository answers 404 with the real endpoint. POST and PUT there have no route; they used to reach the conda or npm handlers and fail with an unrelated filename required or invalid JSON. The body now names POST /api/v1/repositories/REPO/debs (deb) or /packages (rpm). Other formats and methods are unchanged. A test (cmd/server/upload_routes_test.go) keeps the Upload column of the format matrix in line with the registered routes.
  • RPM groups over a proxy of a tree of repositories. A group whose RPM proxy members have no repodata/repomd.xml at their upstream root (a mirror directory holding several repositories in sub-directories) now serves the sub-paths of such a member, for example HOST/repos/GROUP/9/BaseOS/x86_64/os/repodata/repomd.xml and the packages below it, from the proxy as it would, unmerged. When the group has no other package to list, HOST/repos/GROUP/repodata/repomd.xml answers 404 with a message naming the members instead of an empty index. A group that already published an empty index keeps serving that empty index until a package appears in it. See Deb and RPM groups.
  • The annotate permission is now enforced. Until now a permission target could list annotate but it granted nothing. PUT and PATCH of an artifact's properties and DELETE of one property now succeed with either annotate or write on the repository (reading them still needs read); write behaves exactly as before. annotate authorizes nothing else: no uploads, package changes or deletes. A principal that holds annotate without write on a target (the seeded admin-all holds both) can therefore now edit properties on the repositories the target matches; review such targets before upgrading. See RBAC.
  • deb groups over a large proxy build their metadata much faster. Generating the merged Release and Packages indexes of a group over a proxy of a full distribution archive (about 78,000 packages) now reads and parses the upstream indexes and compresses the merged ones concurrently, and no longer reads the cached upstream Packages.xz one byte per system call: on a synthetic archive of that size the first request drops from about 10.3 s to about 3.8 s. The published files are byte-identical to before. A generation now uses several CPU cores for those seconds and holds the parsed indexes in memory (about 570 MB peak on that archive instead of about 285 MB). No action required. ||||||| base

Unreleased ​

No migrations.

  • The web UI is translated into Italian, German, French, Spanish, Brazilian Portuguese, Simplified Chinese and Japanese. The language follows the browser (navigator.languages) until a user picks one under Language in the user menu or on the sign-in page; the choice is stored in that browser's local storage only, nothing on the server changes and no setting is needed. Dates, numbers and sort order follow the language. Anything without a translation is shown in English. See Language. No action required; the web bundle grows by one small chunk per language, downloaded only for users of that language.
  • Fixes found by the new coverage tests. Backup jobs, license policies, storage backends and bundle items created without an explicit timestamp now get the current time instead of the zero time. In the web UI, saving a proxy's settings requires the upstream URL, the AI settings page disables Save and Test when its configuration failed to load, the SBOM tab merges components that share name@version and sorts reports without a date last, and upload progress and status now reach the upload list. No action required.

0.11.0 ​

Migrations 081 to 085. All are rolling-safe: 0.10.0 pods keep serving while the new pods migrate.

  • First-admin provisioning: new bootstrap.* settings (OPENARTIFACT_BOOTSTRAP_ADMIN_PASSWORD_FILE, OPENARTIFACT_BOOTSTRAP_ADMIN_PASSWORD, OPENARTIFACT_BOOTSTRAP_FORCE_PASSWORD_CHANGE, OPENARTIFACT_BOOTSTRAP_ADMIN_TOKEN_FILE, OPENARTIFACT_BOOTSTRAP_ADMIN_TOKEN_TTL, OPENARTIFACT_BOOTSTRAP_GENERATED_PASSWORD_TTL) let an empty instance start with an operator-chosen admin password and an admin API token instead of a printed password (see first login). They are ignored on an existing instance. Behaviour change for new instances only: a generated, printed admin password now stops working for login after 24 hours (bootstrap.generatedPasswordTtl; 0 keeps the old never-expires behaviour); change the password in time or reset it with cmd/resetpw. Existing admins are unaffected. Migration 083_password_expires_at.sql adds the nullable auth_users.password_expires_at (metadata-only ADD COLUMN, rolling-safe: older pods never read it). Run cmd/resetpw from a release that includes this change only after the server has migrated, because it now clears that column.
  • Federation resumes interrupted blob pulls. The peer /federation/v1/blob endpoint now serves Range: bytes=N- (206, 416 for an unsatisfiable range, Accept-Ranges: bytes), and a pulling member downloads each blob into a partial file in a spool directory and continues it after a dropped connection instead of starting over; the digest is still verified over the whole blob. New settings (all optional): federation.spoolDir (OPENARTIFACT_FEDERATION_SPOOL_DIR, default a directory under the OS temp dir; use a persistent volume to resume across restarts, one per instance), federation.maxPartialBytes (10 GiB) and federation.maxPartialAge (24h); a non-positive value is rejected at startup. New metrics openartifact_federation_resumed_total and openartifact_federation_partial_bytes. No migration; rolling-safe, because an older peer that ignores Range just makes the transfer restart from 0. A peer that ends a blob body cleanly before its declared size is now treated as a bad blob (digest_mismatch) like a longer one. Nothing to do on upgrade.
  • The interactive API documentation moved from /swagger/ (Swagger UI loaded from unpkg.com) to a self-hosted Scalar page at /api/reference that needs no internet access. /swagger and /swagger/ answer 301 to the new path, so existing links keep working. "Test request" now sends the CSRF header for session calls. The OpenAPI spec reports the running server version and the AGPL-3.0-or-later licence, and its cookieAuth scheme names the real oa_session cookie. Set api.docs.enabled: false (OPENARTIFACT_API_DOCS_ENABLED=false) to turn the page and the redirects off (404); the spec endpoints stay. A reverse proxy or firewall rule that matched /swagger needs to allow /api/reference too.
  • Migrations 081_drop_dead_columns.sql and 082_drop_redundant_indexes.no_tx.sql remove schema nothing used: blobs.ref_count, release_bundles.source_query, scan_jobs.started_at, and 31 btree indexes that duplicate a unique constraint or are a prefix of a wider index (37 fewer indexes in all once the stored search_tsv columns go in a later release). Inserts get cheaper (10000 npm_versions rows: about 1.3 s before, 0.9 s after, table plus indexes 8.0 MB down to 4.4 MB with every change applied). The upgrade is rolling-safe: 0.10.0 pods never read or write the dropped columns and never name an index, so they keep serving while the new pods migrate. 081 runs in a transaction with a 10 s lock_timeout; each ALTER TABLE takes a brief ACCESS EXCLUSIVE lock without rewriting the table, and retries on the next start if it times out. 082 uses DROP INDEX CONCURRENTLY IF EXISTS and does not block reads or writes. The search_tsv columns (deb_packages, rpm_packages, maven_artifacts, npm_versions) and rpm_repos.signing_key_id / signing_key_fingerprint stay in the schema for now because 0.10.0 still selects them; nothing in this release reads them, and they are dropped one release later. If you ran a build from main that applied an earlier version of 081 (it also dropped those columns), the migrator reports checksum drift for it; fix it with UPDATE schema_migrations SET checksum = '53091a3254773095ff0d335316ad503a4aceeb0828316ca9f008d7520245b6bc' WHERE version = '081_drop_dead_columns.sql'; or reset the development database.
  • Ivy and sbt paths with a . or .. segment or a backslash are now rejected with 404 on upload, download and delete (before, they were stored under those literal coordinates). oa import skips such source paths the same way. Clients never publish them; no action required.
  • Web UI: revoking an API token and deleting a package property now ask for confirmation in the app's dialog instead of the browser's native prompt; downloading a GPG public key signs you out cleanly when the session has expired. No action required.
  • Web UI reorganised into seven areas (Home, Repositories, Packages, Projects, Bundles, Security, Settings); see Web UI. No action required, but URLs moved:
    • Old routes redirect, with the query string kept, so bookmarks and links in documents keep working: /stats to /, /search to /packages, /repos/new to /repos?new=1, /repos/NAME/{edit,settings,browse,graph,trash} to the tabs of /repos/NAME, /security/{vulns,licenses,secrets,ai-analysis} to /security?type=..., /admin and /admin/* to /settings and /settings/* (/admin/system is /settings/health, /admin/gpg is /settings/signing, /admin/storage is /settings/backends), and /admin/projects to /projects?scope=all. Artifact links /repos/NAME/packages/FORMAT/PACKAGE?version=V become /repos/NAME/packages/PACKAGE/versions/V. Update links you publish; the full map is in Web UI.
    • Settings is the instance and account hub. Repository settings (quota, cleanup policies, federation, deletion) are on the repository's Settings tab, project settings on the project. The administration pages are settings sections in seven groups.
    • Access tokens are one list in Settings > Access tokens: personal, project and federation tokens, filtered by type. Project tokens are no longer managed on a project tab; they are created and revoked there (the API routes under /projects/{project}/tokens are unchanged).
    • Projects has a single entry. The admin projects page is gone: every project, and for administrators the whole list, is under Projects (?scope=all); who may create projects is Settings > Project creation policy.
    • The command palette (Ctrl+K, Cmd+K on macOS) also finds settings sections, projects, bundles and users, and looks up a checksum in Packages.
    • Security is visible to project maintainers. /security and GET /api/v1/security/findings list the findings of the repositories of the projects where the caller is maintainer or owner; global admins see all. Developers and viewers get 403 there and the counts on the repository and artifact pages. GET /api/v1/security/health stays admin only.
  • New API endpoints behind the new UI (all documented in the REST API and the generated reference): GET /api/v1/security/findings, /security/findings/summary, /security/findings/export and /security/health (Security findings); GET /api/v1/repositories/{name}/security/summary (counts only, repository read permission); GET /api/v1/tokens; GET /api/v1/repositories/{name}/activity and GET /api/v1/repositories/{name}/usage; GET /api/v1/admin/quotas. Existing endpoints gained parameters or fields: GET /api/v1/repositories returns stats per item and accepts q, flagged, near_quota, unscanned and sort; GET /api/v1/repositories/{name}/packages?group=package and per-version security and scanned_at; GET /api/v1/packages/recent?mine=; GET /api/v1/search with group=package, flagged=true and checksum=; audit entries carry actor_name and subject. Responses only add fields, existing clients keep working.
  • Trash items carry expires_at (when the item is purged) and deleted_by_name (the deleter's name) next to the fields they had; purge_at still holds the same value as expires_at, so oa trash list and scripts are unaffected. Prefer expires_at.
  • One shared rule now decides what a path segment may be (not empty, not . or .., no /, backslash or NUL) in every format handler, proxy, group and upload path. Requests that were accepted before and are now refused with 404: a deb, rpm or Helm file URL whose name is a percent-encoded . or ..; a Debian group request whose distribution is .. or empty, or an RPM or Debian group package path with an empty, . or .. segment or a backslash. A package path or CI upload --target-path with a backslash is also refused. Names that merely contain two dots (foo..bar, ..hidden) are now accepted where they were refused before (RPM header fields, Debian pool paths). Clients do not send the refused shapes; no action required.
  • API robustness fixes found by fuzzing the OpenAPI description. Requests that answered 500 now answer a client error: a malformed id in a webhook, cleanup-policy, backup or admin-token route answers 404; a NUL byte in a URL or JSON body, a NUL in a declarative ownership marker, an unknown user in a group's member list and a cleanup policy name over 255 bytes answer 400. keep_last_n and keep_since_days beyond the 32-bit range are rejected with 400 instead of being wrapped to a negative number. Empty lists in the backups, bundles, cleanup, license, SBOM, secrets, vulnerability and pipeline-stage endpoints now answer [] instead of null; clients that tested for null must treat an empty array as empty. Quarantining an npm version from the UI now also blocks the native tarball download. No action required.

0.10.0 ​

No migrations.

  • New optional white-label branding: branding.* (OPENARTIFACT_BRANDING_*) and Administration > Branding set the product name, logos, favicon, accent colour, login message, support link and source-code link; the unauthenticated GET /api/v1/branding and /manifest.webmanifest serve them. With nothing configured the UI shows the OpenArtifact brand, now with the new logo lockups, favicon, app icons and self-hosted Inter and JetBrains Mono fonts (no request to a font CDN). A "Powered by OpenArtifact" line and a source-code link stay visible in the About dialog and on the login page. web/public/manifest.webmanifest is no longer a static file: the server generates it, so a reverse proxy that serves it directly must forward the path. No migration; settings live in system_settings.
  • S3 staging promotion no longer depends on the backend enforcing If-Match / x-amz-copy-source-if-match: OpenArtifact now also compares the ETag returned by the read or copy and discards the copy on a mismatch. This closes the post-verification overwrite window on backends that ignore conditional reads (for example Garage 1.x). No action required.
  • Uploads no longer hold a database connection and advisory lock while the object store PUT runs. Bytes are written first (content-addressed, idempotent), then the per-blob lock covers only the database rows, after re-checking that the object still exists. Concurrent uploads per pod are no longer capped by the connection pool, and a large upload no longer blocks other uploads or deletes of the same blob. No configuration change.
  • New optional audit log retention: audit.retentionDays (OPENARTIFACT_AUDIT_RETENTION_DAYS) deletes audit entries older than that many days in small paced batches, one replica at a time, with openartifact_audit_retention_* metrics. The default 0 keeps the log forever, so nothing changes unless you set it; nothing is exported before deletion (use oa audit export). No migration.
  • Package search no longer slows down as the set of repositories a user can read grows: planning a query took about 11 ms at 2000 readable repositories and about 50 ms at 10000, and is now about 1.5 ms at any size (a set of up to 1000 repositories is unchanged). Results, their order and totals are unchanged; no migration, nothing to do on upgrade.
  • CI commands and least-privilege CI tokens. oa gains upload, bundle create|add|seal|show and promote (CI integration). To let a non-admin CI token drive them, artifact-property routes now need read/write on the repository (they needed global manage) and the release-bundle routes decide per bundle (creator or admin changes a bundle; read on every source repository to see it; write on the target repository to promote) instead of requiring an admin. Deploy tokens can therefore reach these two route groups; GET /api/v1/bundles (the list) and /api/v1/pipelines stay admin-only. Administrators are unaffected; permission targets need no change. POST /api/v1/bundles accepts a pipeline name next to pipeline_id, and target_repo of a promotion may be omitted when the stage fixes a repository. See release bundle permissions.

0.9.0 ​

Migrations 067 to 071 and 080.

  • Metrics label changes (update dashboards and alerts, see monitoring):
    • openartifact_http_requests_total and openartifact_http_request_duration_seconds: the path label of catch-all routes now keeps its /* (/repos/raw/{name}/*, /repos/maven/{name}/*, /repos/ivy/{name}/*, /repos/sbt/{name}/*, /repos/{name}/*; before, /repos/raw/{name}, ...), the web UI catch-all is /* (before, an empty string), and OCI registry traffic, which was one /v2 series, is split into /v2/{name}/manifests/{reference}, /v2/{name}/blobs/{digest}, /v2/{name}/blobs/uploads, /v2/{name}/blobs/uploads/{uuid}, /v2/{name}/tags/list, /v2/{name}/referrers/{digest} and /v2/*. Queries that matched path="/v2" must use path=~"/v2.*".
    • openartifact_uploads_total{format,result} now covers every format (helm, rubygems, docker, pypi, cargo, go, nuget, conda, conan, ivy, sbt and the direct raw PUT were missing; it had deb, rpm, npm, maven and the presigned raw completion). result is only success or error: the presigned raw upload no longer emits rejected, failure and aborted, and an abort is not counted.
    • New openartifact_db_pool_* metrics for the two database connection pools (pool="pgx" and pool="sql").
  • New optional blob replication: set storage.replica (environment OPENARTIFACT_STORAGE_REPLICA_*) to keep an asynchronous second copy of every blob on another backend, with read fallback, a verify/repair pass and openartifact_replication_* metrics. Migration 067 adds the blob_replication queue and the blob_replication_state table; with no replica configured nothing changes. New admin endpoints GET /api/v1/admin/storage/replication and POST /api/v1/admin/storage/replication/verify. A storage backend record named replication is no longer reachable through GET /api/v1/admin/storage/replication (use the list).
  • Disaster-recovery row journal (migration 068). Every package row change is mirrored as a small JSON sidecar blob in the blob store by a background worker, so openartifact rebuild can re-index an empty database from storage (rebuild from storage). The journal is enabled by default (recovery.journal.enabled, OPENARTIFACT_RECOVERY_JOURNAL_ENABLED=false turns it off).
  • On the first start after the upgrade a backfill writes one sidecar per existing package row. It is paced (it pauses while the queue is long) but on a large installation expect extra blob store writes for a while; watch GET /api/v1/admin/recovery/status and openartifact_recovery_journal_backlog.
  • The migration adds the recovery_* tables and a trigger on each package table (a small insert per changed row). Migrations are forward-only; rolling back the binary leaves the triggers in place; they keep queuing small rows in recovery_dirty until a binary with the journal drains them.
  • New subcommand openartifact rebuild (/app/openartifact rebuild in the container image).
  • Migration 069 builds two indexes on recovery_sidecars (concurrently, so writes are not blocked) that the replication verify pass uses to restore the sidecars of a lost primary store.
  • Federated repositories (federation): migration 070 adds the federation_* tables and the repositories.federated column, and 071 adds capture triggers on the format unit tables. They do nothing until a repository is federated. New federation.* configuration keys (OPENARTIFACT_FEDERATION_*, off by default), new admin endpoints under /api/v1/repositories/{name}/federation and new peer endpoints under /federation/v1. Nothing to do on upgrade.
  • New disaster recovery runbook and PostgreSQL point-in-time recovery guide (CloudNativePG, Barman Cloud plugin, Helm chart). Documentation only.
  • DELETE /api/v1/users/{id} now hard-deletes the account. Before, it only disabled the user and kept the row. The user's API tokens, sessions, project memberships, group links, permission assignments and SSO link are removed, and an SSO login with the same external ID provisions a fresh user. It is refused for your own account and, with 409, for the last enabled admin. To keep an account, disable it with PATCH /api/v1/users/{id} and {"disabled": true}; the Users page now offers separate Disable/Enable and Delete actions.
  • Uploads to the S3 backend: a blob of up to 16 MiB is now stored with a single PutObject that reuses the already verified sha256 as the signed payload hash, instead of going through the transfer manager (which buffered the whole body in memory and hashed it twice more). The integrity guarantee is unchanged (the object store still rejects a body that does not match), memory per upload drops from the blob size to a constant, and larger blobs still use multipart. The spool copy buffer is pooled for every backend. New metric openartifact_blob_operation_duration_seconds and storage.* spans for blob store calls; see monitoring. Nothing to do on upgrade.
  • Migration 080 adds the user.deleted audit action; disabling a user through PATCH now records user.disabled.
  • Uploads no longer buffer the whole body in memory. sbt, Ivy, Conan, Conda, Helm, RubyGems, Go module and Cargo uploads are hashed while they are read; the part above 1 MiB goes to a temporary file in $TMPDIR (one per concurrent upload, removed when the request ends) and is parsed and stored from there. PyPI and NuGet read the multipart part in place instead of copying it again. Heap use per upload is now bounded instead of 2-5x the body size. Size limits, quota checks and responses are unchanged. With read_only: true and tmpfs: /tmp (the shipped compose file) the spool counts against that tmpfs; size it for your concurrent large uploads, or point TMPDIR at a volume. Blob downloads and uploads also copy through pooled buffers.
  • Package search ranks its matches with fewer string operations (the name is lowered once per row and compared with =/starts_with instead of up to four ILIKE and two regular expressions), which cuts about a fifth of the database CPU of a broad /api/v1/search query. Results, their order and totals are unchanged; no migration, nothing to do on upgrade.

0.8.1 ​

Migration 066.

  • New oa apply, oa diff and oa export commands manage users, groups, projects, repositories, permission targets, SSO providers, webhooks, storage tiers and tier policies from YAML manifests; see declarative configuration. Migration 066 adds the declarative_objects table that records which objects oa apply manages (the ownership marker --prune relies on). The table is not part of backups: after a restore, --prune deletes nothing until the next oa apply records ownership again.
  • New admin endpoints GET/PUT/DELETE /api/v1/admin/declarative (ownership markers) and GET /api/v1/permission-targets/{id}/assignments.
  • The container image now ships the oa CLI at /usr/local/bin/oa, so a Kubernetes Job or Argo CD hook can run oa apply from the server image.

0.8.0 ​

Migrations 054, 056–059, 061, 063, 064 and 065. Multi-tenant Entra identities change their external ID to <tid>:<sub> (see SSO changes).

  • Migration 064 flags every hosted RPM repository's metadata stale so the worker regenerates it at the first start (repositories created by 0.7.0 or earlier keep <Location> in primary.xml until then, and dnf cannot install from them). Every upload to an RPM repository now also flags it stale; before, a package uploaded after the first one was missing from the metadata.
  • Cleanup policies now run on every hosted format (npm, maven, ivy, sbt, cargo, rubygems, conda, conan, raw and docker join deb, rpm, helm, pypi, nuget and go). Policies already stored for these formats, which used to fail on every run, now execute on their next schedule: review their keep_* settings, or use the new POST /api/v1/repositories/{repo}/cleanup-policies/{id}/dry-run to see what a run would remove. Quarantined packages are now also skipped by cleanup for every format. See cleanup policies.
  • DELETE /api/v1/repositories/{repo}/packages/{id} now works for every hosted format (it answered 400 UNSUPPORTED_FORMAT for all but deb and rpm); the web UI offers package delete for all of them. Rows go to the trash when the repository keeps one.
  • SPDX documentNamespace and OpenVEX @id are now minted under BASE_URL (urn:openartifact:... when it is empty) instead of openartifact.io. Documents already stored keep their identifiers.
  • Creating or updating a cleanup policy on a repository whose format the cleanup worker cannot process now answers 400 UNSUPPORTED_FORMAT; such policies previously failed on every run.
  • POST /api/v1/admin/storage/{name}/test runs a real bucket or container check for gcs and azure backends instead of answering not_implemented.
  • SBOM components are now read from the stored package file (manifest and lockfile) for npm, pypi, maven, sbt, ivy, cargo, go, nuget, rubygems, helm, conda, deb, rpm and conan, and merged with the dependencies property. SBOMs generated after the upgrade list more components than before. artifact_id may also be a package-listing row id. POST …/sbom/generate answers 422 unsafe_artifact for a package file that cannot be read safely. See SBOM and VEX.
  • The web UI can record, list and delete VEX decisions and download OpenVEX / CycloneDX VEX documents (Security → Vulnerability scans).
  • POST /api/v1/bundles/{name}/{version}/promote now promotes: it copies every item of a sealed bundle into the target repository and records the promotion, instead of answering 501 not_implemented. Clients that treated the 501 as "feature unavailable" must handle 200, and the new 409/403/507 outcomes (Release bundles). Administrators can pass ?force=true to replace conflicting content in the target. Webhooks and SIEM integrations can subscribe to the new bundle.promoted event.
  • Migration 058 adds three tables (additive): two for the Dependency-Track integration and the per-repository switch for attaching attestations to docker images.
  • Signed SBOM and VEX. GET …/sbom/{id}/download and GET …/vex accept ?attest=true (a DSSE envelope, in-toto attestation) or ?attest=bundle (a Sigstore bundle that cosign verify-blob-attestation reads). The key is attestation.keyPath (ATTESTATION_KEY_PATH) or, when unset, an ECDSA P-256 key generated on first use and stored encrypted in system_settings; its public half is at /.well-known/openartifact/attestation-key.pem. Nothing changes for existing callers. See SBOM and VEX.
  • SPDX 3.0.1 JSON-LD: format spdx3-json.
  • Dependency-Track. An admin-configured integration (global, with per-repository overrides) pushes the CycloneDX BOM on upload and after each scan, then the VEX decisions after Dependency-Track's analysis. It is off until configured: nothing leaves the server by default. The URL goes through the same SSRF guard as other outbound requests, so a Dependency-Track on a private address needs OPENARTIFACT_ALLOW_PRIVATE_UPSTREAMS=true. See Dependency-Track.
  • Attestations as OCI referrers. A hosted docker repository can attach the signed SBOM and VEX of an image to it as OCI referrers (POST …/sbom/{id}/attach, POST …/vex/attach, or automatically after SBOM generation and scans with PUT …/attestation-attach). Off by default. See Attaching to an OCI image.
  • oa dtrack, oa sbom download --attest and oa vex get --attest.
  • Migration 061 makes the storage quota counters exact: reservations and deletes leave a short-lived entry in the new quota_pending table, package-row triggers consume it, and the reconcile counts rows plus pending entries instead of overwriting the counter, so a recount no longer drifts by the operations in flight. The triggers add a small cost to package inserts and deletes; the index is built concurrently. See quotas.
  • POST /api/v1/repositories/{name}/registry-gc and the docker GC worker no longer have a 501 not_implemented path; they run on every deployment.
  • Tiered storage now removes the copy a write leaves on the wrong tier when it races a move, and presigned-upload completion follows the blob's placement. See storage tiering.
  • Grype and Trivy scans of deb and rpm packages now run with the distribution release derived from the package, or are routed to osv when it is unknown (or, for Trivy, a Fedora rpm), instead of failing with unsupported_ecosystem. The scanner field shows what ran. See scanning.
  • oa import covers every hosted format. Besides Maven, Debian, RPM and raw it now imports npm, PyPI, Docker/OCI, Helm, NuGet, RubyGems, Cargo, Go, Conda, Conan (v2 layout), Ivy and sbt from Nexus 3 and Artifactory, preserving creation times, with checksum verification and resumable runs. See Migrating from Nexus and Artifactory.
  • X-OA-Import-Created-At is honored by every hosted upload (for manage principals only), not just Maven, raw, Debian and RPM. A backup restore now also keeps the backed-up created_at of those rows instead of stamping the restore time. No migration.
  • Maven poms that declare no license now inherit it from their <parent> pom stored in the same repository or in a group it belongs to (including poms a proxy member already cached), for the upload license policy, the license scan and the SBOM. A blocking policy can therefore reject a child whose parent declares a denied license. Existing scan results are not rewritten: scan the pom again. See Maven parent poms.

Conan 1.x clients (migration 057) ​

A Conan 1.x client can now use a conan repository (see Conan). Migration 057 adds md5 and sha1 columns to conan_packages and an index on sha1 (CREATE INDEX CONCURRENTLY); it also stops the updated_at trigger from firing on a digest-only change, because that column is the revision time. The migration is additive and safe for a rolling upgrade.

  • Files stored by earlier versions have no digests. A Conan 1 client's file listing fills in the MD5 on first read; the SHA-1 is recorded then too. Those files only take part in checksum deploy after that, or after being uploaded again.
  • The ping now advertises revisions,checksum_deploy. Conan 2 clients start sending an empty PUT with X-Checksum-Deploy: true before uploads and fall back to the normal upload on 404; nothing to change on the client.
  • Set BASE_URL to the address clients use: Conan 1 URLs in upload_urls and download_urls are absolute.

Docker catalog, trashed blobs, proxy digest eviction (migration 063) ​

  • GET /v2/_catalog now exists: it lists the docker images the caller may read, paginated with n/last. See Docker.
  • A layer or config blob that only trashed manifests reference now answers 404 BLOB_UNKNOWN by digest until a manifest that references it is restored. Before, it stayed downloadable while the image sat in the trash.
  • Docker proxy repositories used to keep manifests and blobs addressed by digest forever. They are now evicted after workers.proxyDockerDigestTtl (default 720h, env OPENARTIFACT_PROXY_DOCKER_DIGEST_TTL) without a pull, and a pull renews them; the next pull of an evicted digest goes to the upstream again. Set it to 0 to keep the old behavior. Migration 063 gives the entries already cached the default 30 days from the upgrade, so content nobody pulls in that time is evicted by the proxy cache worker.

GPG key management (migration 054) ​

Migration 054 (GPG key management; additive) creates gpg_keys and gpg_key_repositories and widens the chk_audit_action constraint with the gpg_key.* audit actions (NOT VALID, then validated by 054_gpg_keys_validate.no_tx.sql). A rolling upgrade is fine.

  • GPG keys are managed at runtime. /api/v1/admin/gpg/keys, oa gpg and the Admin → GPG keys page now work (they were stubs). A key configured with gpg.keyPath keeps signing exactly as before and appears as a read-only config entry; a key you activate takes precedence over it. Storing keys requires OPENARTIFACT_SECRET_KEY; without it the page shows how to enable it.

  • gpg.autoGenerate now works. With no key configured and none stored, the first start generates an RSA-4096 key (global, active) and logs its fingerprint. It needs OPENARTIFACT_SECRET_KEY; without it a warning is logged and nothing is generated. It was a no-op before, so an existing GPG_AUTO_GENERATE=true starts producing a key on upgrade.

  • APT repositories serve public.key. HOST/repos/REPO/public.key used to return 404 for Debian repositories; it now returns the key that signs the repository, from the next metadata regeneration (or when a key is activated).

  • RPM primary.xml lists packages under <location> (it was written as <Location>, which dnf cannot download from). Fixed; migration 064 flags every hosted RPM repository for regeneration, so they are rewritten at the first start after the upgrade with no new upload. (Debian public.key still appears at the repository's next regeneration: any upload, delete or key change.)

  • Bundles can be sealed with a GPG signature (gpg_key_id, previously 501), with GET .../manifest and .../manifest.asc to verify it.

  • RPM repositories can sign packages. Migration 054 also adds repositories.sign_packages (default false, so nothing changes on upgrade). Enabling it on a hosted RPM repository signs every later upload with the repository's signing key, so dnf works with the default gpgcheck=1; the stored file is the signed one (new SHA-256), existing packages are not re-signed and key rotation does not touch them (RPM). Debian needs no change: apt trusts the signed Release.

  • Deb and RPM group repositories can now be created. Migration 059 adds group_metadata_state and group_package_owners (and one CREATE INDEX CONCURRENTLY); it is additive and a rolling upgrade is fine. Groups of these formats generate their metadata at startup for groups that have none, and when members change; see Deb and RPM groups for what is merged, signing and access control.

Multipart and universal presigned uploads (migration 056) ​

  • Migration 056 adds part_size and multipart_id to presigned_uploads. It is additive; a rolling upgrade is fine.
  • Presigned raw uploads work on every storage backend and files above 5 GiB (up to 5 TiB) are uploaded in parts: POST /api/v1/repositories/{name}/uploads takes multipart and part_size, .../uploads/{id}/parts signs part URLs and .../uploads/{id}/abort discards an upload. oa artifact upload --presigned switches to multipart above 5 GiB (--multipart, --part-size, --parallel). A size above 5 GiB without multipart is refused with 400 invalid_size. See Presigned uploads.
  • Azure gets SAS URLs for uploads and (with presignDownloads) downloads, and a new azureEndpoint / OPENARTIFACT_AZURE_ENDPOINT for Azurite or sovereign clouds.
  • Filesystem and OCI storage serve the upload themselves through PUT /upload-relay/{id}/{part} with a short-lived HMAC-signed URL. It is built from baseUrl, so set BASE_URL to an address clients can reach; without it the create call still answers 501 presign_unsupported. The filesystem backend keeps its staging data in <path>/staging (never listed as blobs); OCI spools in stagingPath / STORAGE_STAGING_PATH.
  • complete timeouts now scale with the object size (30 minutes plus one second per 20 MiB, at most 6 hours), and promoting an S3 object above 4 GiB uses ranged UploadPartCopy.
  • Add an AbortIncompleteMultipartUpload lifecycle rule (2 days) to the bucket as defence in depth; the cleanup worker aborts abandoned multipart uploads itself.

Release bundles in the web UI ​

  • New admin pages Release bundles (/bundles, /bundles/{name}/{version}): list and filter bundles, create drafts, add and remove items, seal (optionally signing the manifest with a GPG key), promote a sealed bundle to the next pipeline stage, archive, delete drafts, and download the manifest and signature. No API, schema or configuration change. See release bundles.
  • MCP server (MCP), now on the official Go SDK (specification 2026-07-28, older revisions are negotiated). No migration. Changes to check if you use MCP_ENABLED=true:
    • The endpoint accepts only Authorization: Bearer. The session cookie and Basic auth no longer work on /mcp; clients that relied on them must send an API token as a bearer. Project tokens are refused with 403.
    • Read-only tokens can now use the read tools: the HTTP gate no longer needs global write, each tool checks the caller's own permission (read on the repository, write for trigger_ai_analysis, global manage for get_vulnerabilities).
    • A request with an Origin header must match the BASE_URL origin or an entry of the new MCP_ALLOWED_ORIGINS, otherwise it gets 403. Browser-based MCP clients on another origin must be added; CLIs and desktop agents send no Origin and are unaffected.
    • New limits: request body 1 MiB (MCP_MAX_BODY_BYTES), 600 requests per minute per principal (MCP_RATE_LIMIT_PER_MINUTE) and 10 trigger_ai_analysis calls per minute (MCP_AI_TRIGGER_PER_MINUTE). 0 disables the two rate limits.
    • JSON-RPC batch arrays are refused with 400 when MCP-Protocol-Version is 2025-06-18 or later. Tools reject unknown arguments (input schema with additionalProperties: false), and argument errors are now tool errors (isError: true) instead of JSON-RPC errors.
    • Results gained content_trust and notice fields (and a trailing NOTE: text block) wherever they carry third-party text, and every tool has an outputSchema and annotations. trigger_ai_analysis now always checks that the artifact exists in the given repository.
    • New public endpoints /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource<MCP_HTTP_PATH> (RFC 9728), and OIDC access tokens whose aud is <BASE_URL><MCP_HTTP_PATH> are accepted for users already linked through an enabled OIDC provider (needs BASE_URL).

SSO (OIDC and SAML) changes ​

SSO changes (see sso.md); no migration beyond the new per-session SSO table, applied automatically:

  • Default OIDC scopes are now openid email profile; groups is no longer requested by default. A provider that relied on the default must set oidc_config.scopes explicitly (for example ["openid","email","profile","groups"]), or its groups claim disappears.
  • Groups semantics. group_mapping replaces SSO-managed memberships only when the IdP actually sent the groups claim/attribute. An absent claim (or an unresolved Entra overage) keeps memberships and logs a warning; a present but empty claim clears them. SAML needs groups_attribute configured and present in the assertion. permission_mapping stays add-only.
  • Error codes. Identity conflicts (email already used by another account, identity bound elsewhere, ambiguous email) now return a generic 403 sso_login_denied instead of 409 email_already_in_use, 409 identity_conflict and 500 email_link_ambiguous; the reason is only in the server log. user_not_provisioned and account_disabled (403) are unchanged. Update clients or scripts that matched the old codes.
  • Logout. POST /api/v1/auth/logout and POST /api/v1/auth/sso/oidc/{slug}/logout require a same-origin request. For an OIDC session whose IdP advertises end_session_endpoint the response is 200 {"end_session_url": ...} (the web UI navigates there) instead of 204. Register BASE_URL/login at the IdP as a post-logout redirect URI. A hint or redirect in the request body is ignored.
  • Session rotation. Every SSO login revokes the pre-login session and issues a new session and CSRF token.
  • Generated keys are persisted. Without AUTH_HMAC_KEY a random key is generated once and stored in system_settings (shared by replicas); without OPENARTIFACT_SAML_SP_CERT/_KEY a self-signed SP keypair is generated once and stored encrypted. A SAML SP certificate previously regenerated at each start is now stable; re-register it at the IdP once. Without OPENARTIFACT_SECRET_KEY the stored SP key is unencrypted. Multi-replica SSO needs no manual key step any more.
  • Entra multi-tenant. Issuers https://login.microsoftonline.com/{common|organizations|consumers}/v2.0 now require allowed_tenants; external IDs for them become <tid>:<sub>, so existing multi-tenant identities will not match (back up and re-link).
  • New OIDC options: type (github, oauth2; github requires allowed_orgs), hosted_domain, require_email_verified (Google always enforces email_verified), graph_groups/graph_endpoint, authorization_url, token_url, userinfo_url, auth_base_url, api_base_url.
  • SAML: IdP metadata is refreshed every idp_metadata_refresh_seconds (default 3600; stale copy served up to 24 h); static idp_metadata_xml never refreshes, so include old and new signing certificates for a rollover. IdP-initiated login stays off unless allow_idp_initiated is true (it carries a login-CSRF risk).

0.7.0 ​

Migrations 041 (scan overview index, CREATE INDEX CONCURRENTLY), 051 (webhook payload format), 052 (VEX statements) and 053 (license source and unresolved licenses). All are additive; a rolling upgrade from 0.6.0 is fine. Migration 041 is numbered below 051–053 on purpose; the migrator applies every file not yet recorded, whatever its number.

License policies read the package itself (migration 053) ​

  • Licenses now come from the uploaded file (wheel METADATA / sdist PKG-INFO, npm package.json, Cargo Cargo.toml, NuGet .nuspec, Maven .pom; RubyGems already did). Upload form fields are only a fallback, so a client can no longer bypass a license policy by omitting or faking them. See licenses.
  • Under an enabled block policy, an upload whose licenses cannot be read from the file is refused with 422 LICENSE_METADATA_UNREADABLE (for example a PyPI .egg/.exe, a damaged archive, an unparsable .pom). A re-scan of such a stored package fails with 422 unreadable_artifact. Without a block policy the client-declared fields stay the fallback.
  • Scan results gain license_source (artifact or client_declared; existing rows read as client_declared) and unresolved_licenses (names such as BSD that do not map to one SPDX id). Existing results are not rewritten: re-scan to refresh them.

SBOM and VEX (migration 052) ​

  • SBOMs default to CycloneDX 1.5 (1.4 and 1.6 via spec_version) with purls, hashes, SPDX license expressions, the dependency graph and vulnerabilities; SPDX 2.3 is available as spdx-json and tag-value. Old format / spec_version values still work.
  • New VEX documents (OpenVEX 0.2.0, CycloneDX VEX) and admin-recorded not_affected decisions: GET /api/v1/repositories/{name}/vex, GET/PUT …/vex/statements, DELETE …/vex/statements/{id}; oa vex. Decisions are included in backups. See SBOM and VEX.

Exports ​

  • Scan exports: GET …/vuln/scans/{id}/export, GET /api/v1/vuln/scans/export, GET …/secrets/scans/export and …/secrets/scans/{id}/export with format=sarif|csv|json. Secret exports never contain the matched value. See scanning.
  • Audit exports: GET /api/v1/admin/audit/export and GET /api/v1/projects/{p}/activity/export (jsonl or csv), oa audit export. The field sets are an append-only contract. See monitoring.
  • New cross-repository scan overview for admins: GET /api/v1/vuln/scans and GET /api/v1/vuln/scans/summary.

OpenTelemetry ​

  • Optional OTLP tracing (and OTLP push of the Prometheus metrics), off by default: OPENARTIFACT_OTLP_ENABLED=true plus the standard OTEL_EXPORTER_OTLP_* variables. Inbound baggage is never forwarded to upstreams, webhooks or AI providers. The server binary is about 14 MB larger. See monitoring.

Docker Compose and the web UI ​

  • The compose file no longer sets container_name; containers are named by the compose project (docker-openartifact-1, …). Scripts that address openartifact / openartifact-postgres by name must use docker compose exec openartifact … instead. The published port is OPENARTIFACT_PORT (default 8080).
  • The web UI is an installable app (web manifest and a service worker that caches only the hashed static assets, never API or repository responses). It needs HTTPS (or localhost); a reverse proxy must pass /sw.js and /manifest.webmanifest through without long-lived caching. See installation.

Prometheus path label ​

  • The path label of openartifact_http_requests_total and openartifact_http_request_duration_seconds was always unknown (the middleware could not see the matched route). It now holds the route pattern (/api/v1/repositories/{name}, trailing /* removed), and unmatched for requests that match no route. Dashboards and alerts that select path="unknown" must be updated; series cardinality stays bounded by the route table.

Trivy and Grype scanner setup ​

AreaChange
Scan behaviortrivy and grype scans no longer download their vulnerability database themselves. A background task downloads it at start and every OPENARTIFACT_SCANNER_UPDATE_INTERVAL (default 6h) into OPENARTIFACT_SCANNER_CACHE_DIR; a scan whose database is not there yet fails at once with scanner_db_unavailable. If you already run trivy / grype on PATH, expect an outbound download of several GB after the upgrade (Trivy about 1.6 GB, Grype about 3.3 GB), or set OPENARTIFACT_SCANNER_OFFLINE=true and provide the databases.
Scanner environmenttrivy and grype subprocesses no longer inherit the server's whole environment (database password, signing keys and the like stay out); they get PATH, HOME, temp, locale, proxy and CA variables, DOCKER_CONFIG and any TRIVY_* / GRYPE_* variable. A setup that passed other variables through to the scanners must rename them to TRIVY_* / GRYPE_*; see scanning. Trivy now refreshes its database in a staging directory, so keep room for a second copy while it updates.
ErrorsA scan that selects a scanner that is not installed now reports scanner is not available on the server (scanner_unavailable), setup guide: <url>; the first part is unchanged, so matching on the old prefix still works. New failure kind database_unavailable.
APINew admin endpoint GET /api/v1/repositories/{name}/vuln/scanners reporting per-scanner readiness; the web UI disables scanners that are not ready.
Runtime installNew opt-in OPENARTIFACT_TRIVY_INSTALL=auto / OPENARTIFACT_GRYPE_INSTALL=auto (default none): the server downloads a pinned, sha256-verified Trivy / Grype release into <cacheDir>/bin when the binary is not configured or on PATH, and executes it. The archives come from OPENARTIFACT_SCANNER_DOWNLOAD_BASE_URL (default https://github.com; an https mirror serving GitHub's release paths). GET …/vuln/scanners gains install_state and the codes scanner_installing, scanner_install_failed, scanner_install_noexec; new metric openartifact_scanner_install_attempts_total. Nothing changes unless you set it. See Runtime download.
ConfigNew scanners section (cacheDir, updateInterval, offline, downloadBaseUrl, trivy.{path,install,dbRepository,javaDbRepository}, grype.{path,install,dbUrl}) with OPENARTIFACT_SCANNER_*, OPENARTIFACT_TRIVY_* and OPENARTIFACT_GRYPE_* environment overrides; see configuration.
DockerNew build argument SCANNERS (none default, trivy, grype, all) and a scanner_cache volume in the compose file. The image now sets OPENARTIFACT_SCANNER_CACHE_DIR=/var/cache/openartifact/scanners (owned by uid 10001, declared a VOLUME) and PATH; the default image otherwise holds the same files.

Search ranking, grouping and the command palette ​

  • GET /api/v1/search no longer returns the results key. It was a copy of packages kept from before 0.5.0; read packages. oa search is updated and its own --format json output keeps results.
  • Results are ordered by relevance, then newest first: an exact name match, then names starting with the query, then other substring matches, with the row id as the last tie-break so paging is stable. Repositories are ranked the same way, then by name. Before, the order was the newest first only.
  • New query parameter group=package returns one hit per package (its latest version) and counts packages in total. The command palette uses it. Without it nothing changes except the order.
  • Migration 040 builds a trigram index on repositories.name (CREATE INDEX CONCURRENTLY, re-runnable, same pg_trgm requirement as migration 030). It is numbered 040 on purpose, so migrations 035 to 039 stay free for changes prepared in parallel; the migrator orders by file name only, so the gap is harmless.
  • The set of repositories a user may read is cached per user for DB_AUTH_CACHE_TTL_MS and dropped by the same writes that clear the auth cache, so a permission change made through this instance applies at once and one made through another replica within the TTL; see Auth lookup cache. 0 disables it.
  • Creating a repository now clears the auth cache too, so a new repository shows up in permission-derived lists at once.
  • Web UI: the Ctrl+K palette tells a too broad query ("Refine your query") from a failed request (inline error with Retry) and from an empty result, cancels the request of an outdated query, lists a package once, and links to /search?q=… for all results. The header trigger is a real button and shows ⌘K on Apple platforms, Ctrl K elsewhere. The duplicate "Open profile" command is gone ("Open settings" opens the same page).

Webhook payload formats (migration 051) ​

  • New per-webhook payload_format: openartifact (the default, body unchanged), cloudevents, slack, teams or discord; see webhooks. Existing webhooks keep openartifact, so nothing changes until a format is chosen. Migration 051 adds the column with a CHECK constraint (a metadata-only change on a small table).
  • artifact.downloaded is no longer an accepted event. It could be subscribed to but nothing ever emitted it; creating or updating a webhook with it now fails with 400, and migration 051 removes it from existing subscriptions. A webhook that subscribed only to it is left with no events and never fires. The artifact.downloaded action of the audit log is unrelated and unchanged.

0.6.0 ​

Recreate upgrade required, and set a real database password first. Stop every 0.5.x replica before 0.6.0 starts (Kubernetes: strategy: Recreate, or scale to 0): migration 025 renames tables the old binaries query. The server no longer starts with the CHANGE_ME example database password, and PostgreSQL must allow the pg_trgm extension. Both are detailed below.

Security hardening and configuration ​

Migrations 028 (delta patch blob index), 029 and 031 (audit actions). Changes that can break an existing deployment or client:

AreaChange
ConfigThe server refuses to start when the database password is still the CHANGE_ME placeholder from the example files. With Docker Compose, set a real POSTGRES_PASSWORD in docker/.env and change it in PostgreSQL (ALTER USER ... PASSWORD ...) before upgrading. A POSTGRES_PASSWORD exported in the shell overrides docker/.env.
ConfigThe legacy shared API_KEY is off by default; set LEGACY_API_KEY_ENABLED=true to keep using it (a warning is logged when API_KEY is set but disabled). DB_MAX_OPEN_CONNS=1 (or negative) is rejected. OPENARTIFACT_DEV is ignored by release builds.
ConfigNew SERVER_MAX_BODY_BYTES / server.maxBodyBytes (default 16 GiB, 0 disables) caps every request body (413); PyPI uploads are capped at 512 MiB, NuGet at 100 MiB, Maven POMs at 4 MiB. Docker Compose now passes OPENARTIFACT_SECRET_KEY and BASE_URL through.
LoginPOST /api/v1/auth/login accepts only a same-origin JSON body (form posts are gone); logout must be same-origin. The session cookie is Secure whenever BASE_URL is https.
SSOOIDC issuers and SAML metadata URLs must be https (http only on loopback). A SAML assertion without email_verified is treated as unverified. SSO usernames are restricted to [A-Za-z0-9_-]{3,64}.
RBACnpm unpublish (PUT .../-rev/...) needs the delete permission, not write. Delta downloads check quarantine of both components. Quarantine also applies to raw HEAD, Go module and Maven checksum requests.
ProxiesX-Forwarded-For walks every header line; the login limiter keys IPv6 clients per /64.
APIUnique-constraint conflicts are 409 (were 500); unknown or non-UUID ids are 404/400. Values beginning with enc: are refused (400) where a secret is accepted. /health no longer reports uptime or the hostname.
DockerHosted Docker endpoints answer 404 on non-docker or non-hosted repositories. Re-uploading an existing Go module version or an ambiguous RubyGems/Helm/PyPI filename is 409.
BackupsArchives now carry permission targets and assignments, group membership and quota limits; restores are exclusive (409 while one runs). Archives from 0.5.x restore without those.
AuditAdmin changes to users, groups, permission targets, SSO providers and tokens issued for other users are audited (user.*, group.*, permission_target.*, sso_provider.*, token.created_for_user).
resetpwDefaults to sslmode=require.

Storage tiers (migration 032) ​

Migration 032 adds blobs.last_accessed_at and blobs.tier_changed_at (nullable, no rewrite), the tables storage_tiers, storage_policies and storage_tier_moves, and seeds the tier hot, which is the configured primary store. 032_storage_tiers_indexes builds an index with CREATE INDEX CONCURRENTLY (re-runnable); migration 034_blobs_tier_promote_index replaces it with the ordered partial index idx_blobs_tier_promote (also concurrent, re-runnable) that serves promote-on-read, and drops the old idx_blobs_tier_reads. Reads are recorded only while a cold tier exists. Nothing moves until you add a tier and a policy: with only hot the mover is a no-op and every blob keeps its placement. Download times start being recorded after the upgrade, so an idle-based policy treats older blobs as "idle since creation". New settings: tiering.* (configuration). See storage tiers.

Consistent backups and deferred blob deletion (migration 035) ​

Migration 035 adds the tables blob_deletions and backup_leases (no rewrite of existing tables). Behaviour changes:

  • full backups read all records from one database snapshot and the blobs from the blobs table of that snapshot instead of listing the store, so they work on S3, GCS, Azure and OCI too. A backup holds a database transaction and up to five connections while it runs; keep idle_in_transaction_session_timeout off or longer than your longest backup. The manifest gains snapshot_at and blob_count.
  • Deleting a blob no longer removes its bytes at once: they are queued and removed by a sweeper after blobDeletion.grace (default 24h) and once no running backup needs them. Disk and bucket usage therefore fall up to a day after a delete or purge. Set OPENARTIFACT_BLOB_DELETION_GRACE=0 to remove bytes as soon as no backup needs them. The same applies to the old copy left by a storage tier move.
  • New settings: backup.leaseTtl, blobDeletion.grace, blobDeletion.sweepInterval, blobDeletion.sweepBatch (configuration).
  • Mixed versions during a rolling update: an older replica still deletes bytes at once, so run no full backup until every replica runs this version.

Database indexes and the pg_trgm extension ​

Migration 030 needs the PostgreSQL pg_trgm extension (CREATE EXTENSION IF NOT EXISTS pg_trgm). It is a trusted extension since PostgreSQL 13, so the database owner can create it; it ships in the official postgres images and in the postgresql-contrib package of distributions that split it. On a managed service, allow-list it first, or run CREATE EXTENSION pg_trgm as an administrator before the upgrade. Without it the migration fails and the server exits at startup.

What changes:

  • The transactional part creates the extension; the no-transaction part 030_perf_indexes_build builds the indexes with CREATE INDEX CONCURRENTLY (reads and writes continue; on a very large database the trigram indexes take minutes). A build interrupted midway leaves an invalid index that the next start drops and rebuilds.
  • New: (repository_id, filename) indexes on deb_packages, rpm_packages and pypi_packages (pool and file downloads were sequential scans), a partial expression index for PyPI names stored with --, 16 trigram indexes behind package search, and indexes for the foreign keys deb_packages.component_id, auth_user_groups.group_id and repository_group_members.member_repo_id.
  • Dropped: the duplicate indexes idx_blobs_sha256, idx_proxy_cache_repository_key and idx_vuln_results_scan_job, which a unique or primary index already covers.
  • The proxy cache eviction and the retention cleanup now work in batches (2000 expired cache entries, 200 package versions per round trip), so one long run no longer rolls back as a whole when it hits its timeout.
  • GET /api/v1/search answers from one query (total and page together). Behavior is unchanged: a query matching more than 10000 rows is still 400 query_too_broad.

Groups hide proxy versions of hosted package names ​

Migration 029 adds repositories.merge_proxy_versions (boolean NOT NULL DEFAULT false).

Existing groups get the new default (hosted members win), which changes what merged views show. For a package name that a readable hosted member holds, the versions, metadata entries and files that proxy members offer under that name disappear from the group (npm, pypi, go, cargo, nuget, maven, helm, docker; raw is unchanged). Names no hosted member holds are unaffected. This closes a dependency-confusion path: a public package with the same name as an internal one could previously add a higher version to the group. To restore the union for a group, an administrator sets merge_proxy_versions=true (PATCH /api/v1/repository/{name}, or the repository's settings page); see hosted members win.

Proxy repositories:

  • Changing a proxy's upstream_url origin (scheme, host, port) or upstream_username in a PATCH clears the stored upstream_password unless the same request sends a new upstream_password. Update credentials together with such changes.
  • The Authorization header is dropped on any redirect that changes scheme or host, and the outbound (SSRF) block list now also covers NAT64, 6to4, Teredo and reserved IPv4 ranges.
  • Concurrent upstream fetches are bounded (16 at a time per server), and the upstream documents the proxy and group parse in memory (pypi, helm, nuget, cargo, OCI tag lists) share one concurrency gate.
  • Maven, deb and rpm files without a published digest are no longer cached "forever" everywhere: archetype-catalog.xml, .deb/.udeb outside a pool/ directory and .rpm outside a Packages/ directory use cache_ttl_seconds, and an HTML 200 answer for a package path is refused with 502. Already cached files keep their old expiry until evicted.

Organizations become projects ​

Requires a Recreate upgrade. Stop all old replicas before starting the new version; do not use a rolling upgrade. The migration renames tables the old binaries still query, so old and new replicas cannot run side by side.

Schema:

  • Tables organizations -> projects and organization_members -> project_members; columns org_id -> project_id (repositories, members, audit log). New table project_groups (group grants on a project) and policy columns on projects.
  • Audit actions for these objects are now project.*.

Role mapping of existing members: admin -> maintainer, member -> the project's old member_role (read -> viewer, write -> developer, admin -> maintainer). owner is unchanged. Where this raises access (a plain member whose old member_role was admin becomes maintainer, which can also delete) it is safe, because organizations could only be managed by global admins, so memberships were created by admins. member_role is removed from the API.

Project tokens (migration 026): auth_users gains kind and project_id, auth_tokens gains project_id, created_by and project_role. Project tokens are not part of backups; reissue them after a restore.

Defaults for existing projects: hosted repositories only, all formats, repository name prefix <slug>-, at most 50 repositories, token TTL 365 days. These policies are stored now and enforced in later releases.

Breaking changes:

AreaChange
API/api/v1/orgs* is replaced by /api/v1/projects* (members and new groups routes included); the list key is projects. The project API is no longer global-admin only: project owners and maintainers manage their own project, see Projects.
APIVerb change: updating a project is PATCH /api/v1/projects/{project} and changing a member's role is PATCH /api/v1/projects/{project}/members/{user_id}; PUT on those two routes is no longer accepted, so clients written against the earlier projects API (including pre-release builds) must switch. Adding a member accepts username as well as user_id; PUT/DELETE …/groups/{group} takes a group id or exact name. A caller who is not a member of a project gets 404 on its routes, the same as for an unknown project. GET /api/v1/projects returns my_role per project and can_create, and a new setting projects.self_create (OPENARTIFACT_PROJECTS_SELF_CREATE, default off) lets any user create projects.
RepositoriesThe org field of repository responses and of POST/PATCH bodies is now project.
CLIoa org is now oa project (new groups, add-group, rm-group; add-member --role takes viewer|developer|maintainer|owner, default viewer; add-member also takes --username; new update and set-role, both sending PATCH).
Audit logThe org_id column/field is project_id.

Project quotas and cleanup defaults:

AreaChange
QuotasA project can carry a storage quota (PUT /api/v1/projects/{project}/quota, global admin only) over the total of its repositories. An upload must fit the repository's limit and the project's (507), and a repository limit above the project limit is refused (400 validation_failed). Counters are maintained only while a project limit is set; reconcile repairs drift. Projects start unlimited, so nothing changes until an admin sets a limit. See project quotas.
ProjectsTransferring a repository moves its storage usage to the target project and fails with 409 project_quota_exceeded when the target's quota cannot take it (usage counters were not moved before). GET /api/v1/projects/{project}/usage reports totals and a per-repository breakdown.
CleanupProjects can define default cleanup policies (/api/v1/projects/{project}/cleanup-policies), applied to hosted repositories without an enabled policy of their own. See project default cleanup policies.
Web UIProject page: new Usage section for all members; Settings gains the project quota (admin-editable) and the cleanup defaults.

Web UI and package listing:

AreaChange
APIGET /api/v1/repositories/{name}/packages accepts q (case-insensitive substring of the package name or version, at most 200 characters; raw and proxy-cache repositories match the name only) and sort (created, the default, newest first, or name). Any other sort value is 400.
Web UIThe artifact browser pages, searches and sorts on the server, so repositories with more than 2000 packages are browsable in full (the grid view is paged too).
Web UIPackage downloads stream to disk without buffering the file in the browser; a refused download (for example a quarantined artifact) shows an error message instead of replacing the page.
Web UIThe Security pages and navigation are shown to administrators only (the scan endpoints already required the global manage permission). A failed session check no longer blanks the app: it opens the sign-in page with a notice and re-checks on the next navigation. Signing out keeps you signed in and shows an error when the server could not end the session.
Web UIInstall commands quote a value only when it contains characters outside A-Za-z0-9._@/+:=-.

Backup archives written by older versions still restore: the old table names organizations and organization_members are accepted.

0.5.1 ​

No migrations. Fixes a startup deadlock between replicas: a replica waiting for the migration lock could deadlock with one running a CREATE INDEX CONCURRENTLY migration (deadlock detected, SQLSTATE 40P01) and exit. Waiting replicas now poll the lock instead of blocking on it.

0.5.0 ​

Before upgrading a multi-replica deployment: stop every pre-0.5.0 replica before the new version starts (Kubernetes: strategy: Recreate, or scale to 0 first). Migrations 018, 019 and 022 change unique indexes and tables that older binaries write to, so old and new replicas cannot run side by side. The runtime image no longer contains a shell: with Helm chart 0.1.4 or older set database.waitForDatabase=false (the server waits for the database itself), or upgrade to chart 0.2.0.

Security: Go module repositories on 0.4.1 and earlier leak zips across repositories and projects. Up to 0.4.1 the Go module upload stored every zip's database row without a digest, so all Go module versions of an instance shared one blob record that points at the first zip ever uploaded. Every .zip download in every Go repository, of any project, returned that one zip: a user who could read any Go repository could read the source of the first module published anywhere on the instance. Upgrade, then check your Go repositories.

  • Affected: every Go repository whose versions were uploaded on 0.4.1 or earlier. Versions uploaded after the upgrade are not affected.
  • Fail closed: the new version answers 404 for any Go module zip whose blob has no valid sha256 and logs refusing to serve go module zip ... re-upload the version or run the gomod repair (repository, module, version). Nothing is served from the shared record any more, even if the repair below cannot fix a row.
  • Automatic repair: at every start a background task, run by one replica at a time (PostgreSQL advisory lock), looks for Go versions (trashed ones included) that still point at such a record. If there are none it exits at once. Otherwise it recovers each real zip from the blob store (the zips of later uploads were stored under their true sha256 and never deleted), checks it (content matches its sha256, every entry under one <module>@<version>/ prefix, its go.mod equals the stored one) and relinks the version to it. The shared record is then deleted from the database; its object is never deleted, because it is a real module's zip. A version that matches several different stored zips (for example two forks publishing the same module path and version) is not guessed: it stays unservable.
  • Re-run on demand: openartifact-server -repair-gomod runs one repair pass and exits (same configuration and environment as the server; 0 when everything was repaired, non-zero when versions remain). It prints a summary and lists every unrepairable repository: module@version; the same lines are logged as warnings at startup.
  • Re-upload what could not be repaired. Those versions keep answering 404; delete them and PUT the zip again (Go modules). On storage backends that cannot list objects (oci) only the zip of the shared record itself can be recovered, so all other affected versions must be re-uploaded.
  • Backups taken on 0.4.1 or earlier: their Go module records carry no zip digest, so restoring skips those versions (logged, and the restore reports as incomplete); re-upload them. Migration 021 adds a database check (blobs_sha256_hex) so a blob record without a 64-hex sha256 can no longer be written; it does not scan existing rows, so the repair can still replace them.
  • Treat the first module's source as having been disclosed to every user who could read any Go repository on the instance, and rotate anything sensitive in it.

Migration 016 runs automatically at first start (adds repositories.presign_downloads and the presigned_uploads table). Default behavior is unchanged: downloads still stream through the server until you opt in to presigned URLs (storage).

Migrations 017 and 018 run at first start: 017 adds the soft-delete columns (deleted_at, deleted_by) to the repository and per-format package tables and the per-repository trash_retention_days and immutable_releases settings; 018 turns the uniqueness constraints of those tables into partial unique indexes over live rows. Neither rewrites table data; see the locking notes in the table below. Behavior changes:

AreaChange
Image baseThe runtime image is now a shell-less hardened base (dhi.io/static, Debian 13): it has no sh, wget, nc or apk. It still runs as uid/gid 10001 with the binary at /app/openartifact; the health check is openartifact-server -healthcheck (a GET on 127.0.0.1:<configured port>/health, exit 0 on 200).
TroubleshootingUse kubectl debug -it <pod> --image=busybox --target=openartifact (an ephemeral container sharing the process namespace) or docker run --rm -it --network container:openartifact busybox; exec into the container no longer works.
Custom CAsMount the CA bundle into the container and point SSL_CERT_FILE (one file) or SSL_CERT_DIR (a directory) at it; update-ca-certificates cannot run in the image.
trivy / grypeThe trivy and grype vulnerability scanner backends need those static binaries; see Installing Trivy and Grype (the Docker build argument SCANNERS bundles them), or use the default osv scanner.
Helm chartChart <= 0.1.4 runs a wait-for-database init container that needs sh and nc from this image and fails with the new image. Upgrade the chart, or set database.waitForDatabase=false: the server now waits itself, retrying the initial database connection with capped backoff for DB_CONNECT_TIMEOUT (database.connectTimeout, default 60s, 0 fails immediately).
Database startupNew DB_CONNECT_TIMEOUT / database.connectTimeout: the server retries the first database connection for up to this long (each wait is logged at info level) instead of exiting at once.
Security (Go)Go module zips are served only when their blob has a real sha256 (404 otherwise), and every blob upsert (all formats) rejects a sha256 that is not 64 lowercase hex characters. Versions published on 0.4.1 and earlier are repaired automatically at start, or with openartifact-server -repair-gomod; see the security note above.
Configdatabase.options and storage.options now have env overrides, DB_OPTIONS and STORAGE_OPTIONS (comma-separated key=value pairs merged over YAML); STORAGE_TYPE is listed in .env.example.
Security (login)The failed-login lockout is now per (username, client IP) instead of per username, so an attacker can no longer lock the real user out from another address.
DockerPushing a manifest whose config or layer blobs are not stored in the repository now fails with 400 MANIFEST_BLOB_UNKNOWN instead of storing a manifest that cannot be pulled.
DockerBlob visibility is now per image: an image can only GET/HEAD/DELETE, mount from or reference in a manifest blobs that were pushed to it, mounted into it or referenced by one of its manifests, so an RBAC path_pattern limited to one image no longer reaches other images' layers in the same repository. Migration 022 adds docker_blob_images and docker_blobs.size_bytes and links existing blobs through the manifests that reference them; images whose manifests are not indexed yet keep access to the repository's existing blobs until registry GC indexes them. Clients that push an image sharing layers with a sibling image of the repository re-send those layers once (or mount them with from=). Blob responses use the verified size and Content-Type: application/octet-stream.
Security (storage)Every blob store (filesystem, S3/GCS, Azure, OCI) now verifies the sha256 it is given against the streamed bytes before the object becomes visible and rejects a mismatch with storage.ErrDigestMismatch; an object that already exists is never overwritten. Docker upload sessions are serialised, so a PATCH racing the completing PUT can no longer change the stored bytes.
DockerThe shared blobs row keeps the first writer's media_type and corrects a recorded size of 0; Go .mod and Cargo blobs record their real size. A manifest push re-checks its blobs under the registry GC lock.
APIGET .../packages/{id}/delta now also finds deb source packages and no longer scans the whole repository. MCP list_ai_analyses honors license_concern; MCP search_artifacts and REST search resolve readable repositories with one permission lookup. Removing a bundle item races safely with sealing (409 if sealed). The OpenAPI spec documents the deb/rpm upload, delete, comps and delta routes and corrected schemas.
ConfigNew opt-in keys storage.presignDownloads, presignMinSize, presignEndpoint, presignUploadTtl, presignMaxPendingUploads (default 20 outstanding uploads per user, 0 = unlimited; env STORAGE_PRESIGN_*). presignTtl (STORAGE_S3_PRESIGN_TTL) now takes effect: it is the lifetime of presigned download URLs, clamped to 1 m - 1 h. A negative presignMinSize stops startup.
DownloadsWith presignDownloads on (s3 and gcs backends), blob downloads of every format answer 307 with a presigned URL; metadata is still streamed. Per-repository override presign_downloads (true, false, null to inherit) on the repository API and in the repository Settings tab.
APINew POST /api/v1/repositories/{name}/uploads and POST /api/v1/repositories/{name}/uploads/{id}/complete for presigned raw uploads (s3 and gcs backends; 501 elsewhere). RBAC treats them as repository write; only the creator or an admin can complete an upload, and complete is idempotent.
CLIoa artifact upload --presigned uploads a raw file straight to the object store; the server then reads it back to verify the sha256.
MetricsNew counter openartifact_blob_downloads_total{mode,reason}.
CaveatsSet presignEndpoint when endpoint is a cluster-internal address clients cannot reach. Clients must reach the object store host directly. The object store answers with Content-Disposition: attachment; filename="...". ivy and sbt clients do not follow redirects across schemes: set presign_downloads to false on those repositories unless the store uses the same scheme. A worker removes abandoned presigned-upload staging objects; a bucket lifecycle rule expiring the staging/ prefix after 1-2 days is recommended as defence in depth.
SSO (SAML)The IdP metadata URL is fetched through the outbound SSRF guard (1 MiB cap, 10 s timeout). Metadata URLs on private or loopback addresses need OPENARTIFACT_ALLOW_PRIVATE_UPSTREAMS=true.
SSO (SAML)Signed responses or assertions with more than 1000 XML elements (large group lists) are accepted again; SAML now uses the openartifact/saml fork of crewjam/saml.
Migration locks017 runs in one short transaction: metadata-only ADD COLUMN on repositories and the 17 per-format package tables, and the audit_logs action constraint re-created NOT VALID (no table scan; new actions are accepted immediately). It holds ACCESS EXCLUSIVE locks only for that moment, whatever the table sizes, and gives up (rolls back; the next start retries) if a lock is not granted within 10 s, rather than queueing every query behind a long-running one such as a backup.
Migration index builds018 runs without a transaction. Per table it builds the live-row unique index and the idx_<table>_deleted index with CREATE INDEX CONCURRENTLY (reads and writes continue; the time is proportional to the table size, and instant on an empty database), then drops the old unique constraint (an ACCESS EXCLUSIVE lock for an instant, abandoned after 10 s and retried at the next start). Uniqueness is enforced at every step. It ends with VALIDATE CONSTRAINT chk_audit_action, which scans audit_logs under SHARE UPDATE EXCLUSIVE and does not block inserts. The server serves requests only once the migrations are done (other replicas wait on the migration lock), so on a very large database run ./openartifact -migrate ahead of the rollout (migrating separately). A 018 that was interrupted or failed is safe to re-run: indexes left invalid by a cancelled build are dropped and rebuilt, finished steps are skipped. Long transactions (a running pg_dump) delay CREATE INDEX CONCURRENTLY; schedule the upgrade outside the backup window.
Rolling upgradeStop the old replicas before the new version migrates. Once 018 has dropped the old unique constraints, a previous version's upserts (ON CONFLICT (...) without the WHERE deleted_at IS NULL predicate) on raw, maven, nuget, docker manifests and docker tags fail with SQLSTATE 42P10, and old binaries do not hide trashed rows. Between 017 and 018 the old constraints still hold, so a previous version keeps working.
DeletesSoft by default. Deleting an artifact, version, tag or repository moves it to the trash for 7 days (OPENARTIFACT_TRASH_RETENTION / trash.retentionDays) instead of destroying it; a background purge removes it afterwards (OPENARTIFACT_TRASH_PURGE_INTERVAL, default 15m). Set OPENARTIFACT_TRASH_RETENTION=0 for the previous immediate behavior, or trash_retention_days per repository.
Repository deleteDELETE /api/v1/repository/{name} needs a confirmation: ?confirm=<name> or a JSON body {"confirm":"<name>"}, otherwise 400 confirmation_required. The CLI takes oa repo delete <name> --confirm <name> (prompts on a terminal, fails without --confirm otherwise). Update scripts that delete repositories.
APINew endpoints GET /api/v1/trash, GET /api/v1/repositories/{name}/trash, restore (POST .../restore) and purge (DELETE /api/v1/trash/{kind}/{id}); repository create/update/read gain trash_retention_days and immutable_releases.
Immutable releasesNew per-repository immutable_releases option (default off): deleting or overwriting a released version is 409 unless an admin passes ?force=true. Cleanup policies never remove released versions of such repositories.
Webhooks and auditNew events artifact.restored, artifact.purged, repository.restored, repository.purged. artifact.deleted and repository.deleted also fire for soft deletes. Review webhook subscriptions that treat *.deleted as "bytes are gone".
QuotasSoft-deleted bytes keep counting against a repository's quota (and the quota reconcile counts them) until the item is purged.
Repository namesThe name of a repository in the trash stays reserved: creating a repository with it is 409 until the old one is purged or restored.
StorageBlobs of trashed items are kept; disk usage drops only when the purge worker runs. Docker GC does not collect trashed manifests.
BackupsItems in the trash are not included in backups (a backup holds live data only).
Repository namesCreating a repository named new, settings, trash, raw, maven, sbt or ivy (case-insensitive) is 400 validation_failed: they collide with web UI routes and isolated format URL namespaces. Existing repositories with those names are not touched (see repositories).
Web UIDeep links (reload, bookmark, new tab) now work for package pages whose name contains / (scoped npm, docker, go modules) and for /repos/<name>/settings (opens the repository's Configuration tab). The package "Properties" tab shows stored properties. Package pages and the security scan pickers look packages up on the server instead of loading at most 2000 rows. Row and bulk delete buttons appear only for deb and rpm repositories (the only formats the server can delete by id). "Empty trash" shows and offers the repository's full trash count regardless of the kind filter. The unused Notifications settings page was removed.
APIGET /api/v1/repositories/{name}/packages accepts package_name (exact listed name) next to format.
CLIoa webhook create and oa webhook repo create no longer accept --secret (visible in ps); use --secret-file FILE (- for stdin) or OA_WEBHOOK_SECRET. oa auth logout --revoke --username U --password-stdin also revokes the saved server-side token. A corrupt config.json no longer blocks oa auth login, help and version. oa import resumes assets without a source SHA-256 (falls back to SHA-1, then size).
CLINew `oa trash list
Security (proxies)X-Forwarded-For is read right to left and the first hop that is not in trustedProxies is the client IP (before: the leftmost entry, which a client could forge). Rate limits, the IP allowlist, login throttling and audit now see the real client. X-Real-IP is honored only when it is not itself a trusted proxy.
Security (IP allowlist)ipAllowlist accepts bare IPs as well as CIDRs, and an unparsable entry now stops startup instead of silently disabling the allowlist.
Security (raw/artifact downloads)All /repos/... responses carry Content-Security-Policy: sandbox; default-src 'none', and HTML, SVG, XML and other actively rendered types are served with Content-Disposition: attachment (stored XSS fix). Browsers download instead of rendering such files.
Security (users)A non-admin can no longer change their own email (PATCH /api/v1/users/{id} returns 403); admins still can. This closes pre-claiming a victim's address to be linked by SSO link_by_email.
Security (CSRF)Basic-token requests carrying Sec-Fetch-Site: cross-site/same-site or a foreign Origin now need the CSRF token like session requests. CLI clients are unaffected.
SessionsThe session and CSRF cookies' Max-Age is the absolute session lifetime (30 days) instead of the 24 h idle window, so the sliding expiry works for active users.
AuditPATCH requests are audited like POST/PUT/DELETE.
SSOA new SSO user whose username already exists gets a suffixed username (alice-1a2b3c) instead of a permanent 500. OIDC discovery, JWKS and token requests now go through the outbound SSRF guard; an IdP on a private address needs OPENARTIFACT_ALLOW_PRIVATE_UPSTREAMS=true. POST /api/v1/admin/sso-providers/{slug}/test returns a fixed failure message (details are in the server log).
SSRFThe cloud metadata addresses 168.63.129.16 and 100.100.100.200, and 0.0.0.0/8, are always blocked, even with ALLOW_PRIVATE_UPSTREAMS.
WebhooksQueued and retried deliveries of a disabled webhook are dropped (marked failed) instead of being sent; webhook.test is unaffected.
CachingDelta downloads (.../packages/{id}/delta) are Cache-Control: private, no-cache unless an anonymous read of a public repository.
Docker loginGET /v2/ (the registry ping behind docker login) now succeeds for any authenticated user, including users whose only grants are scoped to specific repositories (it was 403). It stays 401 for anonymous callers. (AUTH-2)
Docker tagsGET /v2/<name>/tags/list is paginated: n (default 100, at most 1000) and last, with a Link: <...>; rel="next" header when more tags exist. Clients that follow Link (docker, skopeo, crane) see no change; scripts reading the raw JSON now get at most 100 tags per call. Invalid n is 400 PAGINATION_NUMBER_INVALID. (FMTA-2)
Docker uploadManifest PUT bodies over 4 MiB are rejected with 413 SIZE_INVALID. Chunk PATCH/PUT now verify the staged file against the recorded offset instead of appending to stray bytes. (FMTA-13, FMTA-11)
Docker upload sweepAbandoned blob-upload sessions and their staged files are removed after workers.dockerUploadTtl (OPENARTIFACT_DOCKER_UPLOAD_TTL, default 24h, 0 disables); staging directory workers.dockerUploadDir (OPENARTIFACT_DOCKER_UPLOAD_DIR, default /tmp/openartifact/docker-uploads). (FMTA-12)
npmnpm deprecate now works: a publish request that changes only deprecated on existing versions updates the stored version (an empty message un-deprecates). dist-tag changes now refresh the packument ETag immediately, and dist-tag PUT bodies over 1 KiB are rejected. (FMTA-3, FMTA-4, FMTA-13)
npmIn an npm repository, packages named charts, index.yaml, config.json or public.key are reachable again (they were shadowed by the helm, cargo, rpm and deb routes). (FMTA-14)
Mavenfoo-1.0-SNAPSHOT.<ext> and foo-1.0-SNAPSHOT-<classifier>.<ext> resolve to the newest build of that exact file (they could return the wrong classifier or extension). HEAD now answers like GET: snapshot names, metadata, checksums, quarantine and a real Content-Length. (FMTA-5, FMTA-6)
PyPIPackage names use full PEP 503 normalization (runs of -_. collapse to one -); uploads with an empty name or version, or a filename that does not belong to the declared name and version, are 400. Files are resolved by filename, and a filename shared by several packages is 409 on delete. Rows stored by older versions with a run of - remain reachable. (FMTA-7, FMTA-8, FMTA-9)
RawListing a directory (GET /repos/<repo>/dir/) returns only paths under dir/, no longer siblings such as dir-old/. (FMTA-10)
Storage (Azure)Blob uploads sign every x-ms-* header sent: metadata (such as repo_id, content-type) no longer causes 403 AuthenticationFailed, metadata names are sanitized to Azure identifiers (content-type becomes content_type), and empty blobs upload. Proxy caching and rpm/deb metadata on Azure now work. (STOR-2)
Storage (filesystem)At startup, blob-upload-*.tmp files older than 1 h left in the storage directory by a crashed upload are removed. (STOR-11)
Proxy repositoriesA proxy repository keeps at most 10 000 live negative (404) cache entries; further missing paths are not negative-cached (each is fetched from upstream again). (STOR-10)
OperationsThe auth cache (DB_AUTH_CACHE_TTL_MS) can serve a trashed repository on other replicas for up to the TTL; documented in trash.
UploadsHelm chart and npm tarball scans stop after 1 GiB of decompressed data. (FMTA-13)
Backupsfull backups of S3 and Azure stores now contain the blobs (the S3 listing returned object keys, so the archive held none); the OCI backend cannot enumerate blobs and a full backup on it now fails explicitly. Go module repositories and the members of group repositories are now part of a backup, and a restore regenerates rpm, deb and maven metadata. A failed table export or archive write now fails the backup job instead of yielding an incomplete archive.
StorageAzure and OCI blob backends no longer cut transfers off after 30 s (large blobs); dial, TLS and response-header timeouts still apply. The orphan blob scan is windowed and cursor-based. Maven maven-metadata.xml blobs replaced by a regeneration, or left behind by a deleted or purged repository, are now freed.
Cleanup policiesA policy run that removed rpm or deb packages now refreshes the repository's repodata or dists/ index; background workers survive a panic in one pass instead of stopping.
Proxy cacheA close-delimited upstream response (no Content-Length, not chunked) of an immutable file is cached with the repository TTL instead of forever.
Presigned uploadsThe pending-upload cap (presignMaxPendingUploads) is now enforced atomically; completing an upload that already expired no longer deletes its staging object while another complete is still verifying it (409 instead).
Groups and listingsGroup members with equal priority resolve in a stable order; updating a repository together with its member_repos is one transaction. Repository, bundle and policy listings have a stable tie-breaker.
ConfigNew workers.webhookDeliveryRetentionDays (OPENARTIFACT_WEBHOOK_DELIVERY_RETENTION_DAYS, default 30, 0 keeps everything): finished webhook deliveries older than that are deleted hourly. Migration 020 adds the webhook_deliveries indexes (built concurrently).
CargoDownloading a yanked version now answers 200 with the .crate instead of 410: yank only sets "yanked": true in the index, so lockfiles that pin the version keep building (Cargo).
CargoCrate names are matched case-insensitively (index, download, yank, unyank), as cargo lowercases them in URLs. Publishing a name that differs from an existing crate only in case is 409.
CargoGET /api/v1/crates returns one entry per crate (highest non-yanked stable max_version, summed downloads) and meta.total counts matching crates, not returned entries.
Cargoconfig.json of a private repository carries "auth-required": true, and is now also served at /index/config.json, where cargo looks for it. Before, cargo could not read it from a sparse index URL.
CargoPublish validates name (letters, digits, -, _, at most 64, not starting with a digit or -) and vers (full SemVer 2.0, at most 100 characters) with 400; a version that already exists is 409 (was 500), decided before the crate body is read. The declared crate length is no longer allocated up front.
Go modules@v/list and @latest rank versions by SemVer precedence instead of as strings (v1.10.0 above v1.9.0); @latest is the highest release, falling back to the highest pre-release only when there is no release (Go modules).
Go modulesThe version in the URL is case-decoded (!r!c = RC) like the module path, on upload and on every GET; versions are stored and listed in plain form. Rows stored earlier under an encoded form (a version with uppercase letters) are unreachable until uploaded again.
Go modulesUpload requires a canonical version (vMAJOR.MINOR.PATCH[-pre][+incompatible]), the module's go.mod at <module>@<version>/go.mod in the zip (as go mod and golang.org/x/mod/zip produce it) and a module line equal to the URL's module path, else 400. Zips whose go.mod sat under a shortened prefix, or whose module line named another module, are no longer accepted.
Go modulesEvery module version now keeps its own zip. Uploads before this release stored the zip's blob without its digest, so all zips of an instance resolved to the first one ever uploaded and go get of any other version failed with unexpected file. Those versions cannot be repaired from the stored rows: upload them again (delete the old version, or use a new version number).
GPG signingRelease.gpg (Debian) and repodata/repomd.xml.asc (RPM) were armored twice, so gpgv, gpg --verify and dnf repo_gpgcheck=1 rejected them (apt only worked through InRelease); they are now standard ASCII-armored detached signatures. Migration 019 flags every RPM repository for regeneration at startup, which rewrites repomd.xml.asc; a Debian repository gets a valid Release.gpg the next time its indexes are regenerated (any upload or delete). repo_gpgcheck=0 and [trusted=yes] workarounds can be dropped (RPM, Debian).
RPMprimary.xml now carries each package's real installed and archive size (they were 0); packages stored earlier are re-read from storage during the startup regeneration, which also corrects the installed_size and archive_size columns.
RPM uploadHeaders are bounded: more than 65535 index entries or a dependency list over 100000 entries is 400 invalid rpm package, and a file list over 100000 files or 16 MiB of path strings is stored truncated (filelists.xml lists a prefix) instead of exhausting memory (RPM).
RubyGems pushA gem whose metadata.gz is a Marshal stream nested deeper than 64 levels no longer crashes the server (a fatal stack overflow); it is treated as unparseable metadata (RubyGems).
Debian metadataRegeneration deletes a superseded index blob only after re-checking, under the per-blob lock, that no repository's metadata and no package references it, and re-stores any blob a concurrent regeneration removed, so identical indexes shared by several repositories no longer end up as rows without an object (HTTP 500 on apt update).
NuGet restoreGET /v3/package/<id>/index.json (the PackageBaseAddress version list) is served and the .nuspec of a version is downloadable at /v3/package/<id>/<version>/<id>.nuspec. Before, dotnet restore could not resolve any package from the feed (NuGet).
NuGet versionsVersions are stored, listed and matched in NuGet's normalized lower-case form (1.0.0.0 and 1.0 are 1.0.0, 2.0-Beta+abc is 2.0.0-beta); URLs accept any spelling. A nuspec with an invalid version is 400 invalid package. Migration 019 rewrites the stored versions. A live row whose normalized form is already taken by another live version of the same package keeps its old spelling (as does all but the oldest of several that normalize alike) and is not reachable at the normalized URL: delete it or push it again (NuGet).
NuGet pushPushing an id and version that already exist is 409 (was 201 while the stored package kept the old content and the new blob stayed in storage unreferenced); dotnet nuget push --skip-duplicate skips it. Delete the version first to publish it again.
NuGet registrationRegistration pages carry @id, lower and upper, items carry @id and catalogEntry.@id, catalogEntry.listed and published; versions are ordered by SemVer instead of by upload time; new leaf route GET /v3/registration/<id>/<version>.json.
NuGet search/v3/query returns one entry per package with a versions array (@id, version, downloads), the latest stable version (else the highest prerelease) as version, and summed totalDownloads; totalHits, skip and take count packages, not versions. The packageUrl field is gone from entries. Autocomplete lists each id once.
Conda namesPackage names containing - (scikit-learn-1.0-py39_0.tar.bz2) are parsed from the right (build = last part, version = the one before, name = the rest) on upload, download and delete; they were split at the first two hyphens and stored as name scikit, version learn. Migration 019 re-splits rows stored that way, so they appear under their real name and download under the file name repodata always listed (Conda).
Conda .conda.conda files are listed under packages.conda and served under their own name (they were advertised as .tar.bz2 under packages, which conda could not unpack); the .tar.bz2 and the .conda of one build can both be uploaded. Rows stored before 019 are all .tar.bz2, including files uploaded with a .conda name: delete and upload those again. Uploading a file that already exists is 409 (was a 500).
Conda repodataEntries carry build_number, subdir, md5, timestamp, the real depends and, when the package declares them, constrains, license, license_family, noarch, arch, platform, features and track_features, read from info/index.json at upload. Packages stored before 019 keep an empty depends, build number 0 and no md5 or timestamp until uploaded again, so conda cannot resolve their dependencies (Conda).
Conda uploadReading info/index.json for the license policy is bounded: at most 256 MiB of the decompressed archive and 100000 archive entries, so a decompression bomb cannot exhaust CPU or memory. A package whose index lies beyond that, or cannot be read, is accepted without license, dependencies and timestamp.
Migration 019 (nuget, conda)One short transaction: metadata-only ADD COLUMN on conda_packages (extension, build_number, md5, timestamp_ms, index_extra), a bounded UPDATE of nuget_packages.version and of the conda_packages rows with a hyphenated name, and a NOT VALID extension check. The no-transaction part 019_formats_b_indexes builds the new live-row unique index of conda_packages (now including extension) with CREATE INDEX CONCURRENTLY before dropping conda_packages_live_coords_key, then validates the check. A previous version's ON CONFLICT upserts on conda_packages fail after 019: stop old replicas first.
Conan uploadEvery file of a recipe revision and of a package revision is stored under its own name (conanfile.py, conan_export.tgz, conan_sources.tgz, conanmanifest.txt, conan_package.tgz, conaninfo.txt, ...). Before, the second file of a recipe hit a unique constraint and conan upload failed with 500. Download serves the file named in the URL with its real size, the file listing reports real sizes, and the recipe and package revisions in the URL are honored instead of being collapsed into revision 0 (Conan).
Conan protocolNew endpoints: GET /v1/ping and /v2/ping (X-Conan-Server-Capabilities: revisions), users/authenticate and users/check_credentials, .../latest and .../revisions for recipes and packages, HEAD on file URLs, and the DELETE forms for a recipe, a recipe revision, the packages of a revision and one package revision (all answer 200). A revision is listed once its conanmanifest.txt is stored, and a package upload needs its recipe revision to be published (404 otherwise).
Conan overwriteUploading the same bytes again is an idempotent 201; different bytes replace the file. With immutable_releases on, a different upload over an existing file and every delete is 409 (every Conan version is a release).
Conan dataMigration 019 adds package_id, package_revision and filename to conan_packages. Packages stored by earlier releases have no file names and are not offered to Conan clients: upload them again with conan upload "*" -r REPO --confirm. They remain in the UI listing and can be removed with DELETE /v2/conans/<name>/<version>/<user>/<channel>. The UI listing and search show one entry per recipe revision, and with the trash on, each deleted file is its own trash item.
Migration 019 (conan)Metadata-only ADD COLUMN (constant defaults, no rewrite) on conan_packages inside the short transaction. The no-transaction part builds the new per-file live-row unique index with CREATE INDEX CONCURRENTLY before dropping conan_packages_live_coords_key, so uniqueness holds throughout. A previous version keeps working against the new schema as it did before (its rows get an empty file name).
ivy and sbt probesHEAD on a file is answered (status, Content-Length, no body); it was 405, so resolvers that probe with HEAD before fetching could not see the file.
ivy and sbt publishPublishing a file that exists replaces it (same bytes: idempotent 201) instead of failing with 500. With immutable_releases on, a different upload over a release version is 409; -SNAPSHOT versions stay mutable.
ivy and sbt layoutMaven-style paths with a multi-segment group (com/example/lib/1.0/lib-1.0.jar) are stored as group com.example, module lib, version 1.0 (they were split as org com, module example, revision lib, so the immutable-release check saw the wrong version). A dotted first segment (com.example/...) is the same group. Files published earlier under the mis-split coordinates are still served and deletable at their URL; publish them again to move them to the right coordinates (Ivy and sbt).
PyPIinfo.version in /pypi/<pkg>/json is the highest version by PEP 440 order, not by string order (10.0.0 above 9.0.0).
RawConcurrent first uploads to one path of an immutable_releases repository: the loser now gets 409 instead of silently overwriting.
CargoA private repository's 401 carries WWW-Authenticate: Cargo login_url="<baseUrl>/login", so cargo sends its token for the index and downloads.
RubyGemsgem yank --platform yanks only that platform; /api/v1/search with an empty or * query lists all gems (capped at 1000).
DebianPool downloads accept percent-encoded file names (libstdc%2b%2b6_...deb).
RPMprimary.xml <time file=> is the upload time (build= stays the RPM build time) and <location href> is percent-encoded. Uploading another epoch of an N-V-R.A already stored (same file name) is rejected with 409.

There is deliberately no OPENARTIFACT_REPO_DELETE_DELAY: the trash window is the deletion delay.

0.4.1 ​

Security fixes; no migrations. Behavior changes:

AreaChange
AI analysisEndpoints on private or loopback addresses (e.g. a local Ollama) need OPENARTIFACT_ALLOW_PRIVATE_UPSTREAMS=true.
Cargo publishDeclared metadata over 1 MiB or crate over 128 MiB is rejected with 413.
SSOLogin redirects accept only same-origin relative paths; OIDC state and session cookies are Secure under TLS or an https base URL.
Group repositoriesMember content is served with X-Content-Type-Options: nosniff, defaulting to application/octet-stream.

0.4.0 ​

Migrations 008–015 run at first start (RPM repositories regenerate their metadata once; the first Docker GC run backfills manifest references). Behavior changes:

AreaChange
WebhooksDeliveries actually happen now (they silently failed before). Review configured webhooks before upgrading.
Search APIrepositories is now {name, format, description} matched on repository names; version-less packages stay in packages.
RPM upload APIResponse fields are snake_case and include id.
Group repositoriesWrites return 405 (they used to go to the first hosted member). Proxy/group repos for formats other than maven, deb, rpm and raw are rejected at creation.
DockerImage names may contain /; the first path segment is the repository, the rest the image. Untagged manifests are garbage-collected after 24 h (OPENARTIFACT_DOCKER_GC_GRACE_PERIOD).
Licensesblock policies are enforced: uploads with a denied license get 403 and late-detected licenses quarantine the artifact.
ScansA scanner failure is reported as failed/partial, no longer as a clean completed.
OrganizationsOrganization membership granted access to the organization's repositories (since renamed to projects).
Audit APIKeyset paging (cursor, has_more); total may be an estimate (total_exact).
CLIoa search --format json prints {repositories, results}; storage create/update take --config-file; user create --admin and license delete are removed.
StartupFailures exit with status 1 (was 0).
Web UI (trash)A repository's Trash page is no longer admin-only: users with the repository-scoped delete permission can open it and restore items, and Empty trash is offered to users with manage on the repository (the API already enforced both). The global Trash page and Purge now stay admin only.
APIGET /api/v1/repository/{name} now returns permissions (write, delete, manage: the caller's effective repository-scoped permissions); list responses do not.
Web UIQuota tab: a repository with only a max_artifact_count quota is shown as configured, with the count usage. Shared button, switch and confirm dialog components (focus trap, Escape) replace the hand-rolled variants; form controls and switch tracks use a stronger border (3:1 contrast); colours and font sizes come from theme tokens.

Rollback ​

Because migrations are forward-only, going back to an older version after a migration has run means restoring the database backup taken before the upgrade, then starting the older version. An older binary does not refuse a database with newer migrations (it only checks the migrations it knows), but a newer schema is not guaranteed to work with it. Restore the blob store snapshot that matches the database backup if artifacts were uploaded or deleted in between.

Free software under the AGPL-3.0-or-later.