Upgrading
Upgrade by replacing the binary or image; the database schema is migrated forward automatically at startup.
Checklist:
- 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).
- Back up the database and blob store (backup) and make sure the secrets are stored safely.
- Deploy the new version (Docker Compose, Helm, binary).
- Check
GET /health: the JSONversionfield shows the running version andstatusshould behealthy.
Versions and image tags
- The
VERSIONfile at the repository root is the release version. Releases are cut from tagsvX.Y.Z; the release workflow fails unlessVERSIONequals the tag without thev. A tag containing a-(for examplev1.0.0-rc.1) is a pre-release. - Images are published to
ghcr.io/openartifact/openartifactforlinux/amd64andlinux/arm64with tagsX.Y.Z(nov) andX.Y;latestis only moved by tags without a pre-release suffix. Pin an exactX.Y.Zin production. - Release archives (
openartifact_<version>_<os>_<arch>.tar.gz,oa_...) andSHA256SUMSare attached to the GitHub release; see installation and signing to verify them. - A binary built without
-ldflagsreports versiondev. The compose file builds from the checkout and takes the version, commit and build time fromOPENARTIFACT_VERSION,OPENARTIFACT_COMMITandOPENARTIFACT_BUILD_TIME(defaultsdev,unknown,unknown);make docker-upfills them from theVERSIONfile and git.openartifact-server -versionprints 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 theschema_migrationstable (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.sqlrun without one, one statement at a time (needed forCREATE 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 drifterror instead of continuing. - A failed migration is rolled back (transactional files) and the server logs
auto-migrate failedand 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:
./openartifact -migrate # binary: applies migrations and exits
docker compose -f docker/docker-compose.yml run --rm openartifact ./openartifact -migrateWith 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:
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 metadataThis 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.
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, defaulttrue) 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
ReadWriteOncevolume the Deployment usesRecreate, so an upgrade has a short downtime. Object storage orReadWriteManyallows rolling updates. - The generated Secret (
AUTH_HMAC_KEY,OPENARTIFACT_SECRET_KEY) is reused on upgrade through Helm'slookup. That does not work underhelm template: with Argo CD or similar, create the Secret yourself and setsecrets.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:
| Setting | If lost or changed |
|---|---|
OPENARTIFACT_SECRET_KEY | Values 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_KEY | Signs 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 / _KEY | If 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_URL | SAML 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, default0) fixes the few failed requests during a Kubernetes rolling update. OnSIGTERM/SIGINTthe server now flips/readyto503first (/healthstays200), 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 default0nothing changes. If you set it, keepterminationGracePeriodSecondsat or aboveshutdownDelay + shutdownTimeoutplus 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
apkandterraformrepositories and lets you activate GPG keys onpacmanandterraformrepositories; 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 theonly hosted ... repositories are signederror 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: truebecametype: [T, "null"](oranyOfwith a{"type": "null"}member for a nullable$ref), schemaexamplebecameexamples,exclusiveMinimumis numeric, file bodies usecontentMediaTypeinstead offormat: binary, andinfo.licensecarries an SPDXidentifierinstead of aurl. Clients that generate code from/api/v1/openapi.jsonor/api/v1/openapi.yamlneed 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.gemfiles after checking them against the info line's SHA-256; a group merges hosted and proxy members, hosted members winning the names they hold (seemerge_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 ignoreRange, hosted info lines omit theruby:andrubygems:requirements, a group does not servespecs.4.8.gz(gem search -rneeds a single member), and a proxy refetches the wholeversionsfile (about 25 MB for rubygems.org) when its TTL expires. - New format: Arch Linux (
pacman), hosted.format: pacmanrepositories serveHOST/repos/REPO/os/<arch>/to pacman: packages are uploaded with the new endpointPUT /repos/{name}/os/{arch}/{file}(.pkg.tar.zst/.xz/.gzand an optional.sig), the.db/.filesdatabases are generated and signed with the repository's GPG key.repo_type: proxyis supported too (a path mirror of an Arch mirror root, default upstreamhttps://geo.mirror.pkgbuild.com); group repositories are described in the next entry. Migration090(pacman_packages) is additive and rolling-safe: it creates thepacman_packagestable and widenschk_repo_formatto acceptpacman; older instances keep running against the migrated schema but do not know the format. The trash gains the kindpacman.pacmanis 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.sigare signed by the server with that key. See Arch Linux (pacman). - pacman group repositories.
repo_type: groupis now accepted for thepacmanformat: a group merges the databases of hosted pacman repositories and of pacman proxies over Arch mirror sections (upstream_urlsuch ashttps://geo.mirror.pkgbuild.com/extra) per architecture, signs them with the server-wide key and serves packages from the member that owns them. Clients useServer = 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_urlsuch ashttps://conda.anaconda.org/conda-forge, required) or a group of hosted and proxy members. The proxy servesrepodata.json/.zst/.bz2,current_repodataandchanneldata.jsonbyte for byte and only serves packages the upstream repodata lists, verified against its digest;repodata.jlapand sharded repodata answer404. A group mergesrepodata.jsonandcurrent_repodata.json(plain and.zst) with hosted members winning a package name. See conda. Migrationnone; two new queries (ListCondaPackageNames,HasCondaPackageName) read existing tables. - New format: Alpine (apk) repositories (migration
089). A hosted repository of formatapkserves.apkpackages and a signedAPKINDEX.tar.gzper<branch>/<repo>/<arch>directory toapkon 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
089widens thechk_repo_formatcheck withapk(addedNOT VALIDin the short transaction;089_apk_validate.no_txthen validates the existing rows without blocking writes) and creates theapk_packagesandapk_signing_keystables. No existing table is rewritten. - New endpoints.
POST /api/v1/repositories/{name}/apks(upload), andGET/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
apkrepository first and purge it from the trash; the widened constraint and the two empty tables stay in the database and are harmless.
- Migration
- Alpine (apk) repositories can be proxies.
format: apkacceptsrepo_type: proxywith anupstream_urlthat is the mirror root (no default; for examplehttps://dl-cdn.alpinelinux.org/alpine).APKINDEX.tar.gzis passed through unmodified with the upstream signature and cached for the repository TTL; a.apkis cached for good once its checksum matches the upstream index, a package the index does not list answers404and one that does not match502. 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: apkacceptsrepo_type: groupover hosted and apk proxy members. The group publishes anAPKINDEX.tar.gzper<branch>/<repo>/<arch>directory, merged from its members in priority order (hosted members win a package name over proxies unlessmerge_proxy_versionsistrue) and signed with the group's own key, which clients install fromHOST/repos/GROUP/public.key; package files come from the member whose entry the index lists. It needsOPENARTIFACT_SECRET_KEYlike hosted apk repositories. See group repositories. No migration. - Ivy and sbt repositories can be proxies and groups.
format: ivyandformat: sbtnow acceptrepo_type: proxy(a path mirror ofupstream_url, no default; for examplehttps://repo.scala-sbt.org/scalasbt/sbt-plugin-releases) andrepo_type: group. A release file is checked against the.sha256/.sha1/.md5the 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_typeproxyandgroupare now accepted for theconanformat (default upstreamhttps://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;latestand 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 unlessmerge_proxy_versionsistrue. Proxy and group repositories are read-only (conan uploadanswers405).pingof a proxy or group advertisesrevisionsonly. No migration and no action required. See Conan proxies. /healthreports the real storage status. The public/health(and thestatusof/api/v1/system/info,storage_statusof/api/v1/admin/system/info) always saidokfor 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 asdegradedtoo ("status":"error","message":"unavailable", no detail; the cause is logged). The probe is cached for 10 seconds and bounded to 3 seconds, so/healthstays cheap. A filesystem store that is read-only or full now reportserroron/health;/readyis unchanged (S3-family stores only), so such a pod is not taken out of rotation. See monitoring.replicationis a reserved storage backend name, likeprimary:/api/v1/admin/storage/replicationis the replication status endpoint and takes precedence over/{name}. Creating or renaming a record toreplication(orprimary) answers400. No migration touches existing rows: a record that already has one of these names stays in the list and keeps working forPATCHandDELETEby name (andPOST .../{name}/testforreplication), butGET /api/v1/admin/storage/replication(orprimary) 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. Migration087deletes that record when it is still exactly what the installer wrote (namelocal, kindfilesystem, 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; theis_defaultcolumn and its index stay until a later release drops them).GET /api/v1/admin/storagegains a read-onlyprimaryobject (kind, non-secret location, live status, usage),GET /api/v1/admin/storage/primaryreturns it andPOST /api/v1/admin/storage/primary/testprobes it;PATCHandDELETEofprimaryanswer405. See the primary store in the admin API.is_defaultis removed from the storage backend API, the UI andoa storage(--defaultand the DEFAULT column are gone). Nothing ever read it: the default store is the one configured withSTORAGE_*. A request that still sendsis_defaultis accepted and the field ignored. A kept record that was flagged default can now be deleted like any other. The nameprimaryis reserved for the configured store; creating or renaming a record to it answers400.FilesystemStoregains aHealthCheck(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_paththat held a public-only key, a passphrase-protected key withoutgpg.passphrase, or a revoked or expired key used to start and then panic on the first signedInReleaserequest. 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
500with the plain-text bodyInternal Server Error; it now answers500with{"error":"internal server error","code":"internal_error"}andContent-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) andaudit(OPENARTIFACT_OTLP_AUDIT).enabledstays the master switch andtracesdefaults totrue, so an existingenabled: true(orOPENARTIFACT_OTLP_ENABLED=true) keeps tracing exactly as before; setOTEL_TRACES_EXPORTER=noneto export metrics, logs or audit without traces, which also removes the HTTP tracing middleware.enabled: truewith every signal off is accepted and logs one warning at startup.logsandauditstart an OTLP log provider on the same endpoint (no records yet). The compose file has an opt-inotel-collectorprofile with a baselineotelcol-contribconfiguration and passes the OpenTelemetry variables fromdocker/.envto the server. See monitoring. - New format: Terraform and OpenTofu registry (migration
088). A hosted repository of formatterraformis a private registry for modules and providers:terraform initandtofu initinstall from it with acredentialsblock, 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_URLset to the public HTTPS URL) and the publish API. Nothing changes for existing repositories.- Migration
088widens thechk_repo_formatcheck withterraform(addedNOT VALIDin the short transaction, so no table scan runs under the lock;088_terraform_validate.no_txthen validates the existing rows without blocking writes) and creates theterraform_modulesandterraform_providerstables 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 firstterraformrepository only after every replica runs the new version. - New public routes.
GET /.well-known/terraform.json(service discovery) andGET/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 shareAUTH_HMAC_KEY, since tickets are signed with a key derived from it. - Downgrading to a version that does not know the format: delete every
terraformrepository 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).
- Migration
- Terraform and OpenTofu proxy and group repositories.
repo_typeproxyandgroupare now accepted for theterraformformat (no migration). A proxy mirrorsregistry.terraform.io(the defaultupstream_url) orregistry.opentofu.org; its name must be the upstream namespace (hashicorpservesHOST/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 upstreamshasumandSHA256SUMSbefore they are stored and keep the upstream's signing keys. Modules are fetched fromgithub.comsources (git::https://github.com/ORG/REPO?ref=..., the form the public registries use, mapped to a codeload tarball) and plain archive URLs, both with//subdirselectors; they are repacked without the wrapper directory (symlinks, hard links, traversal and oversized archives are refused with502) and pinned to the digest of the first copy, so a moving branch or tag answers502after the cache entry expires. Other go-getter sources (git::on other hosts,s3::,hg::, ...) answer501. 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 sameAUTH_HMAC_KEYon 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/amd64server goes from 55.0 to 44.0 MB (the container image shrinks by the same amount; compressed layer: 19.2 to 17.0 MB),resetpwfrom 19.8 to 10.1 MB,oais unchanged. Two causes: the release builds use-tags grpcnotrace, which drops gRPC'snet/tracedebug pages (EnableTracingwas 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 OTLPprotocol: grpcandhttp. If you build the server from source, add-tags grpcnotraceto get the same size (the binary works without it);make builddoes. 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_UNKNOWNfor the proxy members' images unlessmerge_proxy_versionswastrue, because a hosted docker repository answeredtags/listof any image with200and an empty list, which the group read as "this hosted member holds the image".GET /v2/<repo>/<image>/tags/listof a hosted docker repository now answers404 NAME_UNKNOWNwhile 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 unlessmerge_proxy_versionsis set. Scripts that read the tag list of an image that was never pushed must expect404instead of{"tags":[]};docker,skopeoandcraneare unaffected. No action required. - A request to
/repos/REPO/uploadof a hosted Debian or RPM repository answers404with the real endpoint.POSTandPUTthere have no route; they used to reach the conda or npm handlers and fail with an unrelatedfilename requiredorinvalid JSON. The body now namesPOST /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.xmlat their upstream root (a mirror directory holding several repositories in sub-directories) now serves the sub-paths of such a member, for exampleHOST/repos/GROUP/9/BaseOS/x86_64/os/repodata/repomd.xmland 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.xmlanswers404with 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
annotatepermission is now enforced. Until now a permission target could listannotatebut it granted nothing.PUTandPATCHof an artifact's properties andDELETEof one property now succeed with eitherannotateorwriteon the repository (reading them still needsread);writebehaves exactly as before.annotateauthorizes nothing else: no uploads, package changes or deletes. A principal that holdsannotatewithoutwriteon a target (the seededadmin-allholds 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
ReleaseandPackagesindexes 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 upstreamPackages.xzone 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@versionand 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;0keeps the old never-expires behaviour); change the password in time or reset it withcmd/resetpw. Existing admins are unaffected. Migration083_password_expires_at.sqladds the nullableauth_users.password_expires_at(metadata-onlyADD COLUMN, rolling-safe: older pods never read it). Runcmd/resetpwfrom 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/blobendpoint now servesRange: bytes=N-(206,416for 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) andfederation.maxPartialAge(24h); a non-positive value is rejected at startup. New metricsopenartifact_federation_resumed_totalandopenartifact_federation_partial_bytes. No migration; rolling-safe, because an older peer that ignoresRangejust 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 fromunpkg.com) to a self-hosted Scalar page at/api/referencethat needs no internet access./swaggerand/swagger/answer301to 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 theAGPL-3.0-or-laterlicence, and itscookieAuthscheme names the realoa_sessioncookie. Setapi.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/swaggerneeds to allow/api/referencetoo. - Migrations
081_drop_dead_columns.sqland082_drop_redundant_indexes.no_tx.sqlremove 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 storedsearch_tsvcolumns go in a later release). Inserts get cheaper (10000npm_versionsrows: 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.081runs in a transaction with a 10 slock_timeout; eachALTER TABLEtakes a briefACCESS EXCLUSIVElock without rewriting the table, and retries on the next start if it times out.082usesDROP INDEX CONCURRENTLY IF EXISTSand does not block reads or writes. Thesearch_tsvcolumns (deb_packages,rpm_packages,maven_artifacts,npm_versions) andrpm_repos.signing_key_id/signing_key_fingerprintstay 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 frommainthat applied an earlier version of081(it also dropped those columns), the migrator reports checksum drift for it; fix it withUPDATE 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 importskips 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:
/statsto/,/searchto/packages,/repos/newto/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=...,/adminand/admin/*to/settingsand/settings/*(/admin/systemis/settings/health,/admin/gpgis/settings/signing,/admin/storageis/settings/backends), and/admin/projectsto/projects?scope=all. Artifact links/repos/NAME/packages/FORMAT/PACKAGE?version=Vbecome/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}/tokensare 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.
/securityandGET /api/v1/security/findingslist the findings of the repositories of the projects where the caller is maintainer or owner; global admins see all. Developers and viewers get403there and the counts on the repository and artifact pages.GET /api/v1/security/healthstays admin only.
- Old routes redirect, with the query string kept, so bookmarks and links in documents keep working:
- 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/exportand/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}/activityandGET /api/v1/repositories/{name}/usage;GET /api/v1/admin/quotas. Existing endpoints gained parameters or fields:GET /api/v1/repositoriesreturnsstatsper item and acceptsq,flagged,near_quota,unscannedandsort;GET /api/v1/repositories/{name}/packages?group=packageand per-versionsecurityandscanned_at;GET /api/v1/packages/recent?mine=;GET /api/v1/searchwithgroup=package,flagged=trueandchecksum=; audit entries carryactor_nameandsubject. Responses only add fields, existing clients keep working. - Trash items carry
expires_at(when the item is purged) anddeleted_by_name(the deleter's name) next to the fields they had;purge_atstill holds the same value asexpires_at, sooa trash listand scripts are unaffected. Preferexpires_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-pathwith 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
500now answer a client error: a malformed id in a webhook, cleanup-policy, backup or admin-token route answers404; 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 answer400.keep_last_nandkeep_since_daysbeyond the 32-bit range are rejected with400instead 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 ofnull; clients that tested fornullmust 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 unauthenticatedGET /api/v1/brandingand/manifest.webmanifestserve 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.webmanifestis 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 insystem_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
PUTruns. 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, withopenartifact_audit_retention_*metrics. The default0keeps the log forever, so nothing changes unless you set it; nothing is exported before deletion (useoa 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.
oagainsupload,bundle create|add|seal|showandpromote(CI integration). To let a non-admin CI token drive them, artifact-property routes now needread/writeon the repository (they needed globalmanage) 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/pipelinesstay admin-only. Administrators are unaffected; permission targets need no change.POST /api/v1/bundlesaccepts apipelinename next topipeline_id, andtarget_repoof 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_totalandopenartifact_http_request_duration_seconds: thepathlabel 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/v2series, 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 matchedpath="/v2"must usepath=~"/v2.*".openartifact_uploads_total{format,result}now covers every format (helm,rubygems,docker,pypi,cargo,go,nuget,conda,conan,ivy,sbtand the directrawPUTwere missing; it haddeb,rpm,npm,mavenand the presignedrawcompletion).resultis onlysuccessorerror: the presigned raw upload no longer emitsrejected,failureandaborted, and an abort is not counted.- New
openartifact_db_pool_*metrics for the two database connection pools (pool="pgx"andpool="sql").
- New optional blob replication: set
storage.replica(environmentOPENARTIFACT_STORAGE_REPLICA_*) to keep an asynchronous second copy of every blob on another backend, with read fallback, a verify/repair pass andopenartifact_replication_*metrics. Migration067adds theblob_replicationqueue and theblob_replication_statetable; with no replica configured nothing changes. New admin endpointsGET /api/v1/admin/storage/replicationandPOST /api/v1/admin/storage/replication/verify. A storage backend record namedreplicationis no longer reachable throughGET /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 rebuildcan re-index an empty database from storage (rebuild from storage). The journal is enabled by default (recovery.journal.enabled,OPENARTIFACT_RECOVERY_JOURNAL_ENABLED=falseturns 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/statusandopenartifact_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 inrecovery_dirtyuntil a binary with the journal drains them. - New subcommand
openartifact rebuild(/app/openartifact rebuildin the container image). - Migration
069builds two indexes onrecovery_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
070adds thefederation_*tables and therepositories.federatedcolumn, and071adds capture triggers on the format unit tables. They do nothing until a repository is federated. Newfederation.*configuration keys (OPENARTIFACT_FEDERATION_*, off by default), new admin endpoints under/api/v1/repositories/{name}/federationand 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, with409, for the last enabled admin. To keep an account, disable it withPATCH /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
PutObjectthat 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 metricopenartifact_blob_operation_duration_secondsandstorage.*spans for blob store calls; see monitoring. Nothing to do on upgrade. - Migration
080adds theuser.deletedaudit action; disabling a user throughPATCHnow recordsuser.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. Withread_only: trueandtmpfs: /tmp(the shipped compose file) the spool counts against that tmpfs; size it for your concurrent large uploads, or pointTMPDIRat 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_withinstead of up to fourILIKEand two regular expressions), which cuts about a fifth of the database CPU of a broad/api/v1/searchquery. Results, their order and totals are unchanged; no migration, nothing to do on upgrade.
0.8.1
Migration 066.
- New
oa apply,oa diffandoa exportcommands manage users, groups, projects, repositories, permission targets, SSO providers, webhooks, storage tiers and tier policies from YAML manifests; see declarative configuration. Migration066adds thedeclarative_objectstable that records which objectsoa applymanages (the ownership marker--prunerelies on). The table is not part of backups: after a restore,--prunedeletes nothing until the nextoa applyrecords ownership again. - New admin endpoints
GET/PUT/DELETE /api/v1/admin/declarative(ownership markers) andGET /api/v1/permission-targets/{id}/assignments. - The container image now ships the
oaCLI at/usr/local/bin/oa, so a Kubernetes Job or Argo CD hook can runoa applyfrom 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
064flags 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>inprimary.xmluntil 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 newPOST /api/v1/repositories/{repo}/cleanup-policies/{id}/dry-runto 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 answered400 UNSUPPORTED_FORMATfor 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
documentNamespaceand OpenVEX@idare now minted underBASE_URL(urn:openartifact:...when it is empty) instead ofopenartifact.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}/testruns a real bucket or container check forgcsandazurebackends instead of answeringnot_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
dependenciesproperty. SBOMs generated after the upgrade list more components than before.artifact_idmay also be a package-listing row id.POST …/sbom/generateanswers422 unsafe_artifactfor 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}/promotenow promotes: it copies every item of a sealed bundle into the target repository and records the promotion, instead of answering501 not_implemented. Clients that treated the501as "feature unavailable" must handle200, and the new409/403/507outcomes (Release bundles). Administrators can pass?force=trueto replace conflicting content in the target. Webhooks and SIEM integrations can subscribe to the newbundle.promotedevent.- Migration
058adds 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}/downloadandGET …/vexaccept?attest=true(a DSSE envelope, in-toto attestation) or?attest=bundle(a Sigstore bundle thatcosign verify-blob-attestationreads). The key isattestation.keyPath(ATTESTATION_KEY_PATH) or, when unset, an ECDSA P-256 key generated on first use and stored encrypted insystem_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:
formatspdx3-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 withPUT …/attestation-attach). Off by default. See Attaching to an OCI image. oa dtrack,oa sbom download --attestandoa vex get --attest.- Migration
061makes the storage quota counters exact: reservations and deletes leave a short-lived entry in the newquota_pendingtable, 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-gcand the docker GC worker no longer have a501 not_implementedpath; 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. Thescannerfield shows what ran. See scanning. oa importcovers 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-Atis honored by every hosted upload (formanageprincipals only), not just Maven, raw, Debian and RPM. A backup restore now also keeps the backed-upcreated_atof 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 emptyPUTwithX-Checksum-Deploy: truebefore uploads and fall back to the normal upload on404; nothing to change on the client. - Set
BASE_URLto the address clients use: Conan 1 URLs inupload_urlsanddownload_urlsare absolute.
Docker catalog, trashed blobs, proxy digest eviction (migration 063)
GET /v2/_catalognow exists: it lists the docker images the caller may read, paginated withn/last. See Docker.- A layer or config blob that only trashed manifests reference now answers
404 BLOB_UNKNOWNby 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(default720h, envOPENARTIFACT_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 to0to keep the old behavior. Migration063gives 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 gpgand the Admin → GPG keys page now work (they were stubs). A key configured withgpg.keyPathkeeps signing exactly as before and appears as a read-onlyconfigentry; a key you activate takes precedence over it. Storing keys requiresOPENARTIFACT_SECRET_KEY; without it the page shows how to enable it.gpg.autoGeneratenow works. With no key configured and none stored, the first start generates an RSA-4096 key (global, active) and logs its fingerprint. It needsOPENARTIFACT_SECRET_KEY; without it a warning is logged and nothing is generated. It was a no-op before, so an existingGPG_AUTO_GENERATE=truestarts producing a key on upgrade.APT repositories serve
public.key.HOST/repos/REPO/public.keyused to return404for Debian repositories; it now returns the key that signs the repository, from the next metadata regeneration (or when a key is activated).RPM
primary.xmllists packages under<location>(it was written as<Location>, which dnf cannot download from). Fixed; migration064flags every hosted RPM repository for regeneration, so they are rewritten at the first start after the upgrade with no new upload. (Debianpublic.keystill appears at the repository's next regeneration: any upload, delete or key change.)Bundles can be sealed with a GPG signature (
gpg_key_id, previously501), withGET .../manifestand.../manifest.ascto verify it.RPM repositories can sign packages. Migration
054also addsrepositories.sign_packages(defaultfalse, 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 defaultgpgcheck=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 signedRelease.Deb and RPM group repositories can now be created. Migration
059addsgroup_metadata_stateandgroup_package_owners(and oneCREATE 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
056addspart_sizeandmultipart_idtopresigned_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}/uploadstakesmultipartandpart_size,.../uploads/{id}/partssigns part URLs and.../uploads/{id}/abortdiscards an upload.oa artifact upload --presignedswitches to multipart above 5 GiB (--multipart,--part-size,--parallel). A size above 5 GiB withoutmultipartis refused with400 invalid_size. See Presigned uploads. - Azure gets SAS URLs for uploads and (with
presignDownloads) downloads, and a newazureEndpoint/OPENARTIFACT_AZURE_ENDPOINTfor 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 frombaseUrl, so setBASE_URLto an address clients can reach; without it the create call still answers501 presign_unsupported. The filesystem backend keeps its staging data in<path>/staging(never listed as blobs); OCI spools instagingPath/STORAGE_STAGING_PATH. completetimeouts 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 rangedUploadPartCopy.- Add an
AbortIncompleteMultipartUploadlifecycle 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 (readon the repository,writefortrigger_ai_analysis, globalmanageforget_vulnerabilities). - A request with an
Originheader must match theBASE_URLorigin or an entry of the newMCP_ALLOWED_ORIGINS, otherwise it gets 403. Browser-based MCP clients on another origin must be added; CLIs and desktop agents send noOriginand are unaffected. - New limits: request body 1 MiB (
MCP_MAX_BODY_BYTES), 600 requests per minute per principal (MCP_RATE_LIMIT_PER_MINUTE) and 10trigger_ai_analysiscalls per minute (MCP_AI_TRIGGER_PER_MINUTE).0disables the two rate limits. - JSON-RPC batch arrays are refused with 400 when
MCP-Protocol-Versionis2025-06-18or later. Tools reject unknown arguments (input schema withadditionalProperties: false), and argument errors are now tool errors (isError: true) instead of JSON-RPC errors. - Results gained
content_trustandnoticefields (and a trailingNOTE:text block) wherever they carry third-party text, and every tool has anoutputSchemaand annotations.trigger_ai_analysisnow always checks that the artifact exists in the given repository. - New public endpoints
/.well-known/oauth-protected-resourceand/.well-known/oauth-protected-resource<MCP_HTTP_PATH>(RFC 9728), and OIDC access tokens whoseaudis<BASE_URL><MCP_HTTP_PATH>are accepted for users already linked through an enabled OIDC provider (needsBASE_URL).
- The endpoint accepts only
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;groupsis no longer requested by default. A provider that relied on the default must setoidc_config.scopesexplicitly (for example["openid","email","profile","groups"]), or its groups claim disappears. - Groups semantics.
group_mappingreplaces 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 needsgroups_attributeconfigured and present in the assertion.permission_mappingstays add-only. - Error codes. Identity conflicts (email already used by another account, identity bound elsewhere, ambiguous email) now return a generic
403 sso_login_deniedinstead of409 email_already_in_use,409 identity_conflictand500 email_link_ambiguous; the reason is only in the server log.user_not_provisionedandaccount_disabled(403) are unchanged. Update clients or scripts that matched the old codes. - Logout.
POST /api/v1/auth/logoutandPOST /api/v1/auth/sso/oidc/{slug}/logoutrequire a same-origin request. For an OIDC session whose IdP advertisesend_session_endpointthe response is200 {"end_session_url": ...}(the web UI navigates there) instead of204. RegisterBASE_URL/loginat 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_KEYa random key is generated once and stored insystem_settings(shared by replicas); withoutOPENARTIFACT_SAML_SP_CERT/_KEYa 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. WithoutOPENARTIFACT_SECRET_KEYthe 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.0now requireallowed_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;githubrequiresallowed_orgs),hosted_domain,require_email_verified(Google always enforcesemail_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); staticidp_metadata_xmlnever refreshes, so include old and new signing certificates for a rollover. IdP-initiated login stays off unlessallow_idp_initiatedistrue(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/ sdistPKG-INFO, npmpackage.json, CargoCargo.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
blockpolicy, an upload whose licenses cannot be read from the file is refused with422 LICENSE_METADATA_UNREADABLE(for example a PyPI.egg/.exe, a damaged archive, an unparsable.pom). A re-scan of such a stored package fails with422 unreadable_artifact. Without a block policy the client-declared fields stay the fallback. - Scan results gain
license_source(artifactorclient_declared; existing rows read asclient_declared) andunresolved_licenses(names such asBSDthat 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 asspdx-jsonand tag-value. Oldformat/spec_versionvalues still work. - New VEX documents (OpenVEX 0.2.0, CycloneDX VEX) and admin-recorded
not_affecteddecisions: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/exportand…/secrets/scans/{id}/exportwithformat=sarif|csv|json. Secret exports never contain the matched value. See scanning. - Audit exports:
GET /api/v1/admin/audit/exportandGET /api/v1/projects/{p}/activity/export(jsonlorcsv),oa audit export. The field sets are an append-only contract. See monitoring. - New cross-repository scan overview for admins:
GET /api/v1/vuln/scansandGET /api/v1/vuln/scans/summary.
OpenTelemetry
- Optional OTLP tracing (and OTLP push of the Prometheus metrics), off by default:
OPENARTIFACT_OTLP_ENABLED=trueplus the standardOTEL_EXPORTER_OTLP_*variables. Inboundbaggageis 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 addressopenartifact/openartifact-postgresby name must usedocker compose exec openartifact …instead. The published port isOPENARTIFACT_PORT(default8080). - 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.jsand/manifest.webmanifestthrough without long-lived caching. See installation.
Prometheus path label
- The
pathlabel ofopenartifact_http_requests_totalandopenartifact_http_request_duration_secondswas alwaysunknown(the middleware could not see the matched route). It now holds the route pattern (/api/v1/repositories/{name}, trailing/*removed), andunmatchedfor requests that match no route. Dashboards and alerts that selectpath="unknown"must be updated; series cardinality stays bounded by the route table.
Trivy and Grype scanner setup
| Area | Change |
|---|---|
| Scan behavior | trivy 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 environment | trivy 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. |
| Errors | A 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. |
| API | New admin endpoint GET /api/v1/repositories/{name}/vuln/scanners reporting per-scanner readiness; the web UI disables scanners that are not ready. |
| Runtime install | New 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. |
| Config | New 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. |
| Docker | New 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/searchno longer returns theresultskey. It was a copy ofpackageskept from before 0.5.0; readpackages.oa searchis updated and its own--format jsonoutput keepsresults.- 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=packagereturns one hit per package (its latest version) and counts packages intotal. The command palette uses it. Without it nothing changes except the order. - Migration
040builds a trigram index onrepositories.name(CREATE INDEX CONCURRENTLY, re-runnable, samepg_trgmrequirement as migration030). 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_MSand 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.0disables 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,teamsordiscord; see webhooks. Existing webhooks keepopenartifact, so nothing changes until a format is chosen. Migration051adds the column with a CHECK constraint (a metadata-only change on a small table). artifact.downloadedis 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 migration051removes it from existing subscriptions. A webhook that subscribed only to it is left with no events and never fires. Theartifact.downloadedaction 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): migration025renames tables the old binaries query. The server no longer starts with theCHANGE_MEexample database password, and PostgreSQL must allow thepg_trgmextension. 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:
| Area | Change |
|---|---|
| Config | The 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. |
| Config | The 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. |
| Config | New 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. |
| Login | POST /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. |
| SSO | OIDC 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}. |
| RBAC | npm 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. |
| Proxies | X-Forwarded-For walks every header line; the login limiter keys IPv6 clients per /64. |
| API | Unique-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. |
| Docker | Hosted 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. |
| Backups | Archives 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. |
| Audit | Admin 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). |
resetpw | Defaults 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:
fullbackups read all records from one database snapshot and the blobs from theblobstable 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; keepidle_in_transaction_session_timeoutoff or longer than your longest backup. The manifest gainssnapshot_atandblob_count.- Deleting a blob no longer removes its bytes at once: they are queued and removed by a sweeper after
blobDeletion.grace(default24h) and once no running backup needs them. Disk and bucket usage therefore fall up to a day after a delete or purge. SetOPENARTIFACT_BLOB_DELETION_GRACE=0to 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_buildbuilds the indexes withCREATE 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 ondeb_packages,rpm_packagesandpypi_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 keysdeb_packages.component_id,auth_user_groups.group_idandrepository_group_members.member_repo_id. - Dropped: the duplicate indexes
idx_blobs_sha256,idx_proxy_cache_repository_keyandidx_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/searchanswers from one query (total and page together). Behavior is unchanged: a query matching more than 10000 rows is still400 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_urlorigin (scheme, host, port) orupstream_usernamein aPATCHclears the storedupstream_passwordunless the same request sends a newupstream_password. Update credentials together with such changes. - The
Authorizationheader 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/.udeboutside apool/directory and.rpmoutside aPackages/directory usecache_ttl_seconds, and an HTML200answer for a package path is refused with502. 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->projectsandorganization_members->project_members; columnsorg_id->project_id(repositories, members, audit log). New tableproject_groups(group grants on a project) and policy columns onprojects. - 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:
| Area | Change |
|---|---|
| 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. |
| API | Verb 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. |
| Repositories | The org field of repository responses and of POST/PATCH bodies is now project. |
| CLI | oa 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 log | The org_id column/field is project_id. |
Project quotas and cleanup defaults:
| Area | Change |
|---|---|
| Quotas | A 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. |
| Projects | Transferring 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. |
| Cleanup | Projects 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 UI | Project page: new Usage section for all members; Settings gains the project quota (admin-editable) and the cleanup defaults. |
Web UI and package listing:
| Area | Change |
|---|---|
| API | GET /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 UI | The 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 UI | Package 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 UI | The 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 UI | Install 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). Migrations018,019and022change 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 setdatabase.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
.zipdownload 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
404for any Go module zip whose blob has no valid sha256 and logsrefusing 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, itsgo.modequals 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-gomodruns one repair pass and exits (same configuration and environment as the server;0when everything was repaired, non-zero when versions remain). It prints a summary and lists every unrepairablerepository: 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 andPUTthe 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
021adds 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:
| Area | Change |
|---|---|
| Image base | The 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). |
| Troubleshooting | Use 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 CAs | Mount 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 / grype | The 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 chart | Chart <= 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 startup | New 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. |
| Config | database.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. |
| Docker | Pushing 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. |
| Docker | Blob 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. |
| Docker | The 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. |
| API | GET .../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. |
| Config | New 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. |
| Downloads | With 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. |
| API | New 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. |
| CLI | oa artifact upload --presigned uploads a raw file straight to the object store; the server then reads it back to verify the sha256. |
| Metrics | New counter openartifact_blob_downloads_total{mode,reason}. |
| Caveats | Set 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 locks | 017 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 builds | 018 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 upgrade | Stop 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. |
| Deletes | Soft 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 delete | DELETE /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. |
| API | New 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 releases | New 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 audit | New 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". |
| Quotas | Soft-deleted bytes keep counting against a repository's quota (and the quota reconcile counts them) until the item is purged. |
| Repository names | The name of a repository in the trash stays reserved: creating a repository with it is 409 until the old one is purged or restored. |
| Storage | Blobs of trashed items are kept; disk usage drops only when the purge worker runs. Docker GC does not collect trashed manifests. |
| Backups | Items in the trash are not included in backups (a backup holds live data only). |
| Repository names | Creating 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 UI | Deep 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. |
| API | GET /api/v1/repositories/{name}/packages accepts package_name (exact listed name) next to format. |
| CLI | oa 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). |
| CLI | New `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. |
| Sessions | The 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. |
| Audit | PATCH requests are audited like POST/PUT/DELETE. |
| SSO | A 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). |
| SSRF | The 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. |
| Webhooks | Queued and retried deliveries of a disabled webhook are dropped (marked failed) instead of being sent; webhook.test is unaffected. |
| Caching | Delta downloads (.../packages/{id}/delta) are Cache-Control: private, no-cache unless an anonymous read of a public repository. |
| Docker login | GET /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 tags | GET /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 upload | Manifest 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 sweep | Abandoned 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) |
| npm | npm 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) |
| npm | In 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) |
| Maven | foo-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) |
| PyPI | Package 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) |
| Raw | Listing 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 repositories | A 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) |
| Operations | The auth cache (DB_AUTH_CACHE_TTL_MS) can serve a trashed repository on other replicas for up to the TTL; documented in trash. |
| Uploads | Helm chart and npm tarball scans stop after 1 GiB of decompressed data. (FMTA-13) |
| Backups | full 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. |
| Storage | Azure 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 policies | A 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 cache | A close-delimited upstream response (no Content-Length, not chunked) of an immutable file is cached with the repository TTL instead of forever. |
| Presigned uploads | The 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 listings | Group 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. |
| Config | New 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). |
| Cargo | Downloading 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). |
| Cargo | Crate 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. |
| Cargo | GET /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. |
| Cargo | config.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. |
| Cargo | Publish 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 modules | The 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 modules | Upload 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 modules | Every 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 signing | Release.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). |
| RPM | primary.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 upload | Headers 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 push | A 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 metadata | Regeneration 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 restore | GET /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 versions | Versions 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 push | Pushing 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 registration | Registration 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 names | Package 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 repodata | Entries 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 upload | Reading 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 upload | Every 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 protocol | New 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 overwrite | Uploading 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 data | Migration 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 probes | HEAD 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 publish | Publishing 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 layout | Maven-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). |
| PyPI | info.version in /pypi/<pkg>/json is the highest version by PEP 440 order, not by string order (10.0.0 above 9.0.0). |
| Raw | Concurrent first uploads to one path of an immutable_releases repository: the loser now gets 409 instead of silently overwriting. |
| Cargo | A private repository's 401 carries WWW-Authenticate: Cargo login_url="<baseUrl>/login", so cargo sends its token for the index and downloads. |
| RubyGems | gem yank --platform yanks only that platform; /api/v1/search with an empty or * query lists all gems (capped at 1000). |
| Debian | Pool downloads accept percent-encoded file names (libstdc%2b%2b6_...deb). |
| RPM | primary.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:
| Area | Change |
|---|---|
| AI analysis | Endpoints on private or loopback addresses (e.g. a local Ollama) need OPENARTIFACT_ALLOW_PRIVATE_UPSTREAMS=true. |
| Cargo publish | Declared metadata over 1 MiB or crate over 128 MiB is rejected with 413. |
| SSO | Login redirects accept only same-origin relative paths; OIDC state and session cookies are Secure under TLS or an https base URL. |
| Group repositories | Member 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:
| Area | Change |
|---|---|
| Webhooks | Deliveries actually happen now (they silently failed before). Review configured webhooks before upgrading. |
| Search API | repositories is now {name, format, description} matched on repository names; version-less packages stay in packages. |
| RPM upload API | Response fields are snake_case and include id. |
| Group repositories | Writes 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. |
| Docker | Image 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). |
| Licenses | block policies are enforced: uploads with a denied license get 403 and late-detected licenses quarantine the artifact. |
| Scans | A scanner failure is reported as failed/partial, no longer as a clean completed. |
| Organizations | Organization membership granted access to the organization's repositories (since renamed to projects). |
| Audit API | Keyset paging (cursor, has_more); total may be an estimate (total_exact). |
| CLI | oa search --format json prints {repositories, results}; storage create/update take --config-file; user create --admin and license delete are removed. |
| Startup | Failures 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. |
| API | GET /api/v1/repository/{name} now returns permissions (write, delete, manage: the caller's effective repository-scoped permissions); list responses do not. |
| Web UI | Quota 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.