Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cbd2360
Accept referrers that are pushed before their subject
vanlueckn Sep 24, 2026
488c915
Allow anonymous pulls of configured repositories
vanlueckn Sep 24, 2026
301b12a
Accept sparse image indexes
vanlueckn Sep 24, 2026
7554e8b
Enforce immutable release tags
roshanjonah Jul 14, 2026
803a5de
fix: preserve Content-Length on blob and manifest GET/HEAD responses
m-ferrero Jun 20, 2026
bc1d37c
fix: reject manifest PUT whose digest reference does not match the co…
m-ferrero Jun 20, 2026
6d60c86
fix(upload): return proper 4xx and handle final-chunk/empty blobs ins…
m-ferrero Jun 20, 2026
cd77abe
fix(r2): return source digest/size for mounted blob HEAD
mushanyoung Feb 20, 2026
06650dd
fix(r2): compare full digest strings in mounted-blob symlink detection
m-ferrero Jun 21, 2026
98beb41
test: cover cross-repo mounted-blob HEAD source metadata
m-ferrero Jun 21, 2026
643993e
Handle range header in request
fangpenlin Aug 6, 2026
72625db
Fix range not forwarded to upstream issue
fangpenlin Aug 7, 2026
48f8c29
Support suffix byte ranges for blob pulls
cursoragent Aug 13, 2026
aeda3af
Return an upstream 416 instead of masking it as a 404
cursoragent Aug 13, 2026
48fee33
Fix: blocking PATCH requests and implement true streaming for chunked…
Feb 6, 2026
ba6669e
revert: Add back the deleted comments
Feb 25, 2026
aa258f4
fix: infer manifest mediaType from Content-Type when omitted
dnygate Sep 15, 2026
f4209a3
chore: name the offending field in manifest validation errors
dnygate Sep 15, 2026
d2cbdb4
Make content-addressed writes idempotent
EricAndrechek Oct 1, 2026
0a5304a
Add DISABLE_DELETE to make the registry append-only
EricAndrechek Oct 1, 2026
fdab0de
Export the fetch handler and env types for embedding
EricAndrechek Oct 1, 2026
fd7d599
Document anonymous pulls and buckets with retention rules
EricAndrechek Oct 1, 2026
5b5d042
Run the OCI conformance suite on pull requests
EricAndrechek Oct 1, 2026
7e9d08d
Report the main-branch conformance suite in the job summary
EricAndrechek Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/scripts/start-registry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Starts the registry with `wrangler dev` in the background, using the test configuration (user
# "hello", password "world"), and waits until it answers.
#
# start-registry.sh PORT STATE_DIR
set -euo pipefail
port=$1
state=$2
mkdir -p "$state"

WRANGLER_SEND_METRICS=false nohup pnpm exec wrangler dev --config test/wrangler.test.jsonc --env dev \
--ip 127.0.0.1 --port "$port" --inspector-port 0 --persist-to "$state/r2" >"$state/wrangler.log" 2>&1 &
echo $! >"$state/wrangler.pid"

for _ in $(seq 1 120); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/v2/")" = "401" ]; then
echo "registry is up on port $port"
exit 0
fi
sleep 1
done

echo "registry did not start" >&2
cat "$state/wrangler.log" >&2
exit 1
145 changes: 145 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Conformance

# Runs the OCI distribution-spec conformance suite against the registry served by `wrangler dev`.

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
conformance:
name: OCI conformance (v1.1.1)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Checkout the conformance suite
uses: actions/checkout@v6
with:
repository: opencontainers/distribution-spec
ref: v1.1.1
path: distribution-spec
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Use Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Use Go
uses: actions/setup-go@v6
with:
go-version-file: distribution-spec/conformance/go.mod
cache-dependency-path: distribution-spec/conformance/go.sum

- run: pnpm install
- name: Build the conformance suite
working-directory: distribution-spec/conformance
run: go test -c -o "$RUNNER_TEMP/conformance.test"
- name: Start the registry
run: ./.github/scripts/start-registry.sh 5000 "$RUNNER_TEMP/registry"
- name: Run the conformance suite
env:
OCI_ROOT_URL: http://127.0.0.1:5000
OCI_NAMESPACE: conformance/repo1
OCI_CROSSMOUNT_NAMESPACE: conformance/repo2
OCI_USERNAME: hello
OCI_PASSWORD: world
OCI_TEST_PULL: 1
OCI_TEST_PUSH: 1
OCI_TEST_CONTENT_DISCOVERY: 1
OCI_TEST_CONTENT_MANAGEMENT: 1
OCI_HIDE_SKIPPED_WORKFLOWS: 0
OCI_DEBUG: 0
run: |
mkdir -p "$RUNNER_TEMP/results"
cd "$RUNNER_TEMP/results"
OCI_REPORT_DIR="$RUNNER_TEMP/results" "$RUNNER_TEMP/conformance.test"
- name: Show the registry log
if: failure()
run: tail -n 200 "$RUNNER_TEMP/registry/wrangler.log"
- name: Upload the report
if: always()
uses: actions/upload-artifact@v6
with:
name: conformance-v1.1.1
path: ${{ runner.temp }}/results
if-no-files-found: ignore

conformance-main:
# The suite on distribution-spec's main branch is still changing, so this job only reports: a
# failing suite is shown in the job summary but does not fail the job.
name: OCI conformance (main, non-blocking)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Checkout the conformance suite
uses: actions/checkout@v6
with:
repository: opencontainers/distribution-spec
ref: main
path: distribution-spec
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Use Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Use Go
uses: actions/setup-go@v6
with:
go-version-file: distribution-spec/conformance/go.mod
cache-dependency-path: distribution-spec/conformance/go.sum

- run: pnpm install
- name: Build the conformance suite
working-directory: distribution-spec/conformance
run: go build -o "$RUNNER_TEMP/conformance" .
- name: Start the registry
run: ./.github/scripts/start-registry.sh 5000 "$RUNNER_TEMP/registry"
- name: Run the conformance suite
id: suite
continue-on-error: true
shell: bash
env:
OCI_VERSION: "1.1"
OCI_REGISTRY: 127.0.0.1:5000
OCI_TLS: disabled
OCI_REPO1: conformance/repo1
OCI_REPO2: conformance/repo2
OCI_USERNAME: hello
OCI_PASSWORD: world
# sha512 digests are not supported yet
OCI_DATA_SHA512: "false"
OCI_LOG: error
run: |
mkdir -p "$RUNNER_TEMP/results"
cd "$RUNNER_TEMP/results"
OCI_RESULTS_DIR="$RUNNER_TEMP/results" "$RUNNER_TEMP/conformance" | tee "$RUNNER_TEMP/results/output.txt"
- name: Summarize
if: always()
env:
OUTCOME: ${{ steps.suite.outcome }}
run: |
{
echo "### OCI conformance, distribution-spec main: $OUTCOME"
echo '```'
sed -n '/OCI Conformance Result/,$p' "$RUNNER_TEMP/results/output.txt" 2>/dev/null || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload the report
if: always()
uses: actions/upload-artifact@v6
with:
name: conformance-main
path: ${{ runner.temp }}/results
if-no-files-found: ignore
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,52 @@ docker rmi ubuntu:latest $REGISTRY_URL/ubuntu:latest
docker pull $REGISTRY_URL/ubuntu:latest
```

### Allowing anonymous pulls

Set `ANONYMOUS_PULL_REPOSITORIES` to a comma or space separated list of repository names that can be pulled without
credentials. `*` matches any characters, including `/`, so `public/*` allows every repository under `public/` and `*`
allows all of them. Requests without an `Authorization` header can then read the manifests, blobs, tags and referrers
of those repositories. Everything else still needs credentials: pushes, deletes, uploads, `/v2/_catalog` and garbage
collection. `/v2/` keeps answering `401` so that clients still log in before they push, requests with wrong
credentials are still refused, and anonymous requests never use the pull fallback below.

### Protecting immutable release tags

Set `IMMUTABLE_TAG_PATTERN` under `[env.production.vars]` to a JavaScript regular expression that must match the
entire protected tag. For example, this protects strict `vX.Y.Z` releases while leaving `latest` mutable:

```toml
IMMUTABLE_TAG_PATTERN = 'v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)'
```

Protected tags are created with an atomic conditional R2 write. Retrying the same manifest digest is idempotent;
attempting to assign a different digest returns `409` with the OCI `DENIED` error code. Protected tags cannot be
deleted directly. While the policy is enabled, the API rejects every
delete-by-digest request because alias discovery and digest deletion cannot be made atomic across R2 keys. Delete an
unprotected tag by name and let untagged garbage collection remove its content. Direct blob deletion is also disabled
because deleting a referenced layer or config would make a protected release unpullable. An invalid expression fails
manifest writes before any manifest object is stored.

The policy is enforced at the Worker API boundary. To preserve the invariant, restrict direct R2 write access and
route registry writes through this Worker.

### Disabling deletion

Set `DISABLE_DELETE = "true"` to make the registry append-only. Deleting manifests (by tag or by digest), deleting
blobs and garbage collection (`POST /v2/<name>/gc`) then answer `405 Method Not Allowed` with the OCI `UNSUPPORTED`
error code. Pushing, and moving a tag that is not protected by `IMMUTABLE_TAG_PATTERN`, keep working. Cancelling an
upload in progress is not affected, because it only removes temporary upload state.

### Using buckets with retention rules

Blobs, manifests stored under their digest and referrer entries are written once and never overwritten: a push of
content that already exists leaves the stored object alone. The registry therefore works on an R2 bucket whose
content keys are protected by [bucket locks](https://developers.cloudflare.com/r2/buckets/bucket-locks/) or other
retention rules. Those keys are `<repository>/blobs/<digest>`, `<repository>/manifests/sha256:<hex>` and
`<repository>/_referrers/<subject digest>/<referrer digest>`. Tags (`<repository>/manifests/<tag>`) and upload state
are rewritten and deleted, so keep them outside such rules, and set `DISABLE_DELETE` so that deletes fail cleanly
instead of hitting the lock.

### Configuring Pull fallback

You can configure the R2 registry to fallback to another registry if
Expand Down Expand Up @@ -163,6 +209,41 @@ REGISTRIES_JSON = "[{ \"registry\": \"https://index.docker.io/\" }]"

You can also set your `docker.io` credentials in the configuration to not have any rate-limiting.

### Using the registry from another Worker

The package can be a dependency of another Worker that does its own routing and hands registry requests to the
registry. Wrangler bundles the TypeScript sources directly, so there is no build step. Pin a commit:

```jsonc
// package.json of your Worker
"dependencies": {
"r2-registry": "github:cloudflare/serverless-registry#<commit>"
}
```

```ts
import registry, { type RegistryEnv } from "r2-registry";

interface Env extends RegistryEnv {
// your own bindings
}

export default {
async fetch(request, env, ctx) {
const { pathname } = new URL(request.url);
if (pathname === "/v2" || pathname.startsWith("/v2/")) {
return registry.fetch(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
```

`registry.fetch(request, env, ctx)` takes the same bindings and variables as a standalone deployment (`RegistryEnv`):
an R2 bucket bound as `REGISTRY`, and the authentication variables described above. The Worker needs the
`nodejs_compat` compatibility flag. The registry only answers paths under `/v2/`, and it uses the request URL for
authentication challenges and upload locations, so pass the request through with its path unchanged.

### Known limitations

Right now there is some limitations with this container registry.
Expand Down
54 changes: 43 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/**
* The core server that runs on a Cloudflare worker.
*
* Another Worker can import this module and delegate requests to it, see "Using the registry from
* another Worker" in the README.
*/

import { Router } from "itty-router";
Expand All @@ -8,22 +11,38 @@ import v2Router from "./src/router";
import { authenticationMethodFromEnv } from "./src/authentication-method";
import { Registry } from "./src/registry/registry";
import { R2Registry } from "./src/registry/r2";
import { anonymousPullRepository } from "./src/anonymous";

// A full compatibility mode means that the r2 registry will try its best to
// help the client on the layer push. See how we let the client push layers with chunked uploads for more information.
type PushCompatibilityMode = "full" | "none";
export type PushCompatibilityMode = "full" | "none";

export interface Env {
/**
* The bindings and variables the registry reads. A Worker that embeds the registry passes an object
* of this shape, usually its own env, to `fetch`.
*/
export interface RegistryEnv {
REGISTRY: R2Bucket;
ENVIRONMENT: string;
ENVIRONMENT?: string;
JWT_REGISTRY_TOKENS_PUBLIC_KEY?: string;
USERNAME?: string;
PASSWORD?: string;
READONLY_USERNAME?: string;
READONLY_PASSWORD?: string;
PUSH_COMPATIBILITY_MODE?: PushCompatibilityMode;
REGISTRIES_JSON?: string; // should be in the format of RegistryConfiguration[];
// Tags matching this regular expression (the whole tag) are create-only, see src/registry/tag-policy.ts
IMMUTABLE_TAG_PATTERN?: string;
// Set to "true" to refuse every delete: manifests, blobs and garbage collection
DISABLE_DELETE?: string;
// Repositories that can be pulled without credentials, see src/anonymous.ts
ANONYMOUS_PULL_REPOSITORIES?: string;
}

/** The env seen by the routes: the configuration plus state that fetch() sets for each request. */
export interface Env extends RegistryEnv {
REGISTRY_CLIENT: Registry;
ANONYMOUS_REQUEST?: boolean;
}

const router = Router();
Expand All @@ -35,8 +54,8 @@ router.all("/v2/*", v2Router.fetch);

router.all("*", () => new Response("Not Found.", { status: 404 }));

export default {
async fetch(request: Request, env: Env, context?: ExecutionContext) {
const handler = {
async fetch(request: Request, env: RegistryEnv, context?: ExecutionContext): Promise<Response> {
if (!ensureConfig(env)) {
return new AuthErrorResponse(request);
}
Expand All @@ -46,16 +65,26 @@ export default {
return new AuthErrorResponse(request);
}

let anonymous = false;
const credentials = await authMethod.checkCredentials(request);
if (!credentials.verified) {
console.warn(`Not Authorized. authmode=${authMethod.authmode}. verified=false`);
return new AuthErrorResponse(request);
// Requests without any credentials may pull repositories listed in ANONYMOUS_PULL_REPOSITORIES.
// Wrong or expired credentials are still rejected, so clients notice them. /v2/ keeps answering
// 401 with a Basic challenge, which is what makes docker send credentials for pushes.
anonymous = request.headers.get("Authorization") === null && anonymousPullRepository(env, request) !== null;
if (!anonymous) {
console.warn(`Not Authorized. authmode=${authMethod.authmode}. verified=false`);
return new AuthErrorResponse(request);
}
}

env.REGISTRY_CLIENT = new R2Registry(env);
// env is shared by all concurrent requests of this isolate, so everything that depends on the
// request goes into a copy.
const requestEnv = { ...env, ANONYMOUS_REQUEST: anonymous } as Env;
requestEnv.REGISTRY_CLIENT = new R2Registry(requestEnv);
try {
// Dispatch the request to the appropriate route
const res = await router.fetch(request, env, context);
const res = await router.fetch(request, requestEnv, context);
return res;
} catch (err) {
if (err instanceof Response) {
Expand All @@ -79,9 +108,12 @@ export default {
return new InternalError();
}
},
} satisfies ExportedHandler<Env>;
} satisfies ExportedHandler<RegistryEnv>;

export { handler };
export default handler;

const ensureConfig = (env: Env): boolean => {
const ensureConfig = (env: RegistryEnv): boolean => {
if (!env.REGISTRY) {
console.error(
"env.REGISTRY is not setup. Please setup an R2 bucket and add the binding in your wrangler config file. Try 'npx wrangler --env production r2 bucket create r2-registry'",
Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
"description": "An open-source R2 registry",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"exports": {
".": {
"types": "./index.ts",
"default": "./index.ts"
},
"./package.json": "./package.json"
},
"files": [
"index.ts",
"src"
],
"scripts": {
"deploy": "wrangler deploy --minify --env production",
"dev:miniflare": "wrangler dev --env dev --port 9999 --live-reload",
Expand Down
Loading
Loading