Skip to content

Latest commit

 

History

History
1118 lines (883 loc) · 26.1 KB

File metadata and controls

1118 lines (883 loc) · 26.1 KB

Migration Guide: From rules_oci to rules_img

This guide provides step-by-step instructions for migrating from rules_oci to rules_img.

Table of Contents

  1. Understanding the Differences
  2. MODULE.bazel Setup
  3. Building Layers
  4. Building Images
  5. Multi-Platform Images
  6. Output Groups
  7. Interoperability with rules_oci
  8. Container Structure Tests
  9. Deployment

Understanding the Differences

Both rules_oci and rules_img build OCI-compliant container images with Bazel, but they have fundamentally different architectural approaches:

rules_oci

  • Uses OCI image layout as on-disk representation at every step
  • Uses off-the-shelf, pre-built tools for assembling images
  • Downloads all base image layers during the pull phase

rules_img

  • Uses Bazel providers that contain just enough information for subsequent steps
  • Uses custom-built tools optimized for specific workflows
  • Performs shallow pulling - only downloads manifests/configs, not layer blobs
  • Enables advanced optimizations like lazy push, CAS integration, and eStargz support

MODULE.bazel Setup

rules_oci (Before)

bazel_dep(name = "rules_oci", version = "2.0.0")
# Most users either create images with rules_pkg or tar.bzl.
bazel_dep(name = "rules_pkg", version = "0.10.1")

oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
oci.pull(
    name = "distroless_cc",
    digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
    image = "gcr.io/distroless/cc-debian12",
    platforms = ["linux/amd64", "linux/arm64"],
)
use_repo(oci, "distroless_cc")

rules_img (After)

bazel_dep(name = "rules_img", version = "<version>")
# rules_pkg and tar.bzl are no longer needed.

pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")

pull(
    name = "distroless_cc",
    digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
    registry = "gcr.io",
    repository = "distroless/cc-debian12",
)

Key Changes:

  • oci.pull() becomes pull() (a repository rule, not an extension)
  • image parameter splits into separate registry and repository parameters
  • registries allows defining mirror registries that host the same image.
  • platforms parameter is removed (single- and multi-platform images just work without extra configuration)
  • No need for use_repo() - the pull() rule directly creates a repository

Building Layers

There are three approaches to migrating layers from rules_oci:

Option 1: Use existing layers (Tar files) Directly (Quickest Migration)

Both rules_oci and rules_img can consume pkg_tar and tar.bzl targets directly.

rules_oci

load("@rules_pkg//pkg:tar.bzl", "pkg_tar")

pkg_tar(
    name = "app_layer",
    srcs = ["//cmd/server"],
    package_dir = "/app/bin",
)

oci_image(
    name = "image",
    tars = [":app_layer"],
)

rules_img

load("@rules_pkg//pkg:tar.bzl", "pkg_tar")

pkg_tar(
    name = "app_layer",
    srcs = ["//cmd/server"],
    package_dir = "/app/bin",
)

image_manifest(
    name = "image",
    layers = [":app_layer"],  # Can use tar file directly!
)

Key Change: tars attribute becomes layers attribute.

Option 2: Use layer_from_tar (Better Control)

For more control over compression and optimization:

rules_img

load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("@rules_img//img:layer.bzl", "layer_from_tar")

pkg_tar(
    name = "app_tar",
    srcs = ["//cmd/server"],
    package_dir = "/app/bin",
)

layer_from_tar(
    name = "app_layer",
    src = ":app_tar",
    compress = "zstd",     # Recompress with zstd
    optimize = True,       # Deduplicate files
)

image_manifest(
    name = "image",
    layers = [":app_layer"],
)

Benefits:

  • Control compression algorithm (gzip, zstd, or none)
  • Enable optimization to deduplicate identical files
  • Add layer annotations
  • Calculate layer digest in shared target

Option 3: Use image_layer (Recommended)

The native image_layer rule provides the best integration with rules_img:

rules_oci

load("@rules_pkg//pkg:tar.bzl", "pkg_tar")

pkg_tar(
    name = "app_layer",
    srcs = [
        "//cmd/server",
        "//configs:prod_config",
    ],
    package_dir = "/app",
)

rules_img

load("@rules_img//img:layer.bzl", "image_layer")

image_layer(
    name = "app_layer",
    srcs = {
        "/app/bin/server": "//cmd/server",
        "/app/config/prod.json": "//configs:prod_config",
    },
    compress = "zstd",  # Optional: defaults to global setting
)

Benefits:

  • More intuitive path mapping (destination → source)
  • Deduplicates files using hardlinks
  • Built-in support for file metadata, symlinks, and permissions
  • Better performance (single Bazel action writes layer blob and layer metadata)

Advanced: Layer with Custom Metadata

load("@rules_img//img:layer.bzl", "image_layer", "file_metadata")

image_layer(
    name = "secure_layer",
    srcs = {
        "/app/bin/server": "//cmd/server",
        "/etc/app/config": "//configs:prod",
        "/etc/app/secret": "//secrets:api_key",
    },
    default_metadata = file_metadata(
        mode = "0644",
        uid = 1000,
        gid = 1000,
    ),
    file_metadata = {
        "/app/bin/server": file_metadata(mode = "0755", uid = 0, gid = 0),
        "/etc/app/secret": file_metadata(mode = "0600"),
    },
    symlinks = {
        "/usr/bin/server": "/app/bin/server",
    },
)

Building Images

Basic Image

rules_oci

load("@rules_oci//oci:defs.bzl", "oci_image")

oci_image(
    name = "app_image",
    base = "@distroless_cc",
    tars = [":app_layer"],
    entrypoint = ["/app/bin/server"],
    env = {
        "PORT": "8080",
        "ENV": "production",
    },
    labels = {
        "org.opencontainers.image.version": "1.0.0",
        "org.opencontainers.image.source": "https://github.com/myorg/myapp",
    },
)

rules_img

load("@rules_img//img:image.bzl", "image_manifest")

image_manifest(
    name = "app_image",
    base = "@distroless_cc",
    layers = [":app_layer"],
    entrypoint = ["/app/bin/server"],
    env = {
        "PORT": "8080",
        "ENV": "production",
    },
    labels = {
        "org.opencontainers.image.version": "1.0.0",
        "org.opencontainers.image.source": "https://github.com/myorg/myapp",
    },
)

Key Changes:

  • oci_imageimage_manifest
  • tarslayers

Attribute Mapping

rules_oci rules_img Notes
base base ✅ Same
tars layers ⚠️ Renamed
entrypoint entrypoint ✅ Same
cmd cmd ✅ Same
created created ✅ Same
env (dict) env ✅ Same
env (label to file) env_file ⚠️ In rules_oci, env accepts either a dict or a label pointing to a file of KEY=VALUE lines. In rules_img, use the separate env_file attribute for file-based env vars. Values from env take precedence over env_file entries.
labels labels ✅ Same
annotations annotations ✅ Same
user user ✅ Same
workdir working_dir ⚠️ Renamed
exposed_ports N/A ⚠️ Use config_fragment
volumes N/A ⚠️ Use config_fragment
os Uses Bazel target platform ⚠️ Not manually configurable. See below
architecture Uses Bazel target platform ⚠️ Not manually configurable. See below

Unlike rules_oci, users cannot set the image's os and architecture directly. Instead, rules_img uses the target platform of bazel (--platforms=...). Most users should use the image_index rule to configure the target platform of the image. In rare cases where you need to build a single-architecture image (image_manifest) for a specific platform, you can set the platform attribute. This ensures that the layers of the image are actually built for the target platform (and there is no way to accidentally build images with layers for a different platform).

Advanced Configuration

For attributes not directly supported (like exposed_ports or volumes), use config_fragment:

rules_img

# config.json
{
  "config": {
    "ExposedPorts": {
      "8080/tcp": {},
      "9090/tcp": {}
    },
    "Volumes": {
      "/data": {}
    }
  }
}

# BUILD.bazel
image_manifest(
    name = "app_image",
    base = "@distroless_cc",
    layers = [":app_layer"],
    entrypoint = ["/app/bin/server"],
    config_fragment = "config.json",
)

Refer to the OCI specification to learn about all possible fields and their meaning.


Multi-Platform Images

rules_oci

load("@rules_oci//oci:defs.bzl", "oci_image", "oci_image_index")

oci_image(
    name = "app",
    tars = [":app_layer"],
)

oci_image_index(
    name = "app_multiarch",
    images = [
        ":app",
    ],
    platforms = [
        "//platforms:linux_amd64",
        "//platforms:linux_arm64",
    ],
)

rules_img (Platform Transitions - Recommended)

rules_img also offers a multi-platform transition:

load("@rules_img//img:image.bzl", "image_manifest", "image_index")
load("@rules_img//img:layer.bzl", "image_layer")

image_layer(
    name = "app_layer",
    srcs = {
        "/bin/app": "//cmd/app",
    },
)

# Single manifest definition
image_manifest(
    name = "app",
    base = "@distroless_cc",
    layers = [":app_layer"],
    entrypoint = ["/bin/app"],
)

# Automatically builds for multiple platforms!
image_index(
    name = "app_multiarch",
    manifests = [":app"],  # Single manifest
    platforms = [
        "//platforms:linux_amd64",
        "//platforms:linux_arm64",
    ],
)

rules_img (Explicit Manifests)

In rare cases, you can leave platforms empty and manually specify the manifests to include. Most users should not need this.

image_manifest(
    name = "app_amd64",
    layers = [":app_layer"],
    platform = "//platforms:linux_amd64",
    labels = {"architecture": "amd64"},
)

image_manifest(
    name = "app_arm64",
    layers = [":app_layer"],
    platform = "//platforms:linux_arm64",
    labels = {"architecture": "arm64"},
)

image_index(
    name = "app_multiarch",
    manifests = [
        ":app_amd64",
        ":app_arm64",
    ],
)

Output Groups

rules_img supports output groups for accessing different image formats.

Important

In rules_img, base images are shallow by default. In practice, this means that Bazel doesn't have access to layer blobs of externally pulled base images in build actions. When building oci layouts, those layers are needed, so you can change the layer_handling attribute of the pull rule if you run into issues.

OCI Layout Directory

rules_oci

# Access via default info: the image label refers to the tree artifact directly
oci_image(
    name = "app_image",
    tars = [":app_layer"],
)

You can access the oci layout directory using the :app_image label:

bazel build //path/to:app_image

rules_img

image_manifest(
    name = "app_image",
    layers = [":app_layer"],
)

# Create filegroup to surface the output group containing the oci layout directory
filegroup(
    name = "image_layout",
    srcs = [":app_image"],
    output_group = "oci_layout",
)

# Another option: build as tarball
filegroup(
    name = "image_layout_tar",
    srcs = [":app_image"],
    output_group = "oci_tarball",
)

Build with:

# To build the oci layout
bazel build //path/to:image_layout
# alternatively, you can also access output groups directly:
bazel build //path/to:app_image --output_groups=oci_layout
# The same works for the oci layout tar
bazel build //path/to:image_layout_tar
bazel build //path/to:app_image --output_groups=oci_tarball

Docker Save Format Tarball

For compatibility with docker load:

rules_oci

load("@rules_oci//oci:defs.bzl", "oci_load")

oci_load(
    name = "tarball",
    image = ":app_image",
    repo_tags = ["myapp:latest"],
)

filegroup(
    name = "docker_tarball",
    srcs = [":tarball"],
    output_group = "tarball",
)

rules_img

load("@rules_img//img:load.bzl", "image_load")

image_load(
    name = "load",
    image = ":app_image",
    tag = "myapp:latest",
)

filegroup(
    name = "docker_tarball",
    srcs = [":load"],
    output_group = "tarball",
)

Note: The Docker tarball is only available for single-platform images in rules_img.


Interoperability with rules_oci

rules_img and rules_oci can interoperate using OCI layout directories, enabling gradual migration or using both rulesets in the same project.

Using rules_img Images in rules_oci

rules_img images can be consumed by rules_oci via the oci_layout output group:

# rules_img image
load("@rules_img//img:image.bzl", "image_manifest")

image_manifest(
    name = "base_app",
    layers = [":app_layer"],
    entrypoint = ["/app/bin/server"],
)

# Export as OCI layout
filegroup(
    name = "base_app_layout",
    srcs = [":base_app"],
    output_group = "oci_layout",
)

The OCI layout can be used directly in rules_oci targets:

load("@rules_oci//oci:defs.bzl", "oci_image", "oci_push", "oci_load")

# Use as base image
oci_image(
    name = "extended_image",
    base = ":base_app_layout",
    tars = [":additional_layer"],
)

# Push directly
oci_push(
    name = "push",
    image = ":base_app_layout",
    repository = "ghcr.io/myorg/myapp",
)

# Load directly
oci_load(
    name = "load",
    image = ":base_app_layout",
    repo_tags = ["myapp:latest"],
)

Using rules_oci Images in rules_img

rules_oci images can be converted to rules_img using the conversion rules. See docs/convert.md for full documentation.

load("@rules_oci//oci:defs.bzl", "oci_image")
load("@rules_img//img:convert.bzl", "image_manifest_from_oci_layout")
load("@rules_img//img:media_types.bzl", "GZIP_LAYER")

# rules_oci image
oci_image(
    name = "legacy_image",
    base = "@distroless_cc",
    tars = [":legacy_layer"],
)

# Convert to rules_img
image_manifest_from_oci_layout(
    name = "converted_image",
    src = ":legacy_image",
    architecture = "amd64",
    os = "linux",
    layers = [GZIP_LAYER],
)

The converted image can then be used with rules_img rules:

load("@rules_img//img:image.bzl", "image_manifest")
load("@rules_img//img:push.bzl", "image_push")

# Build on the converted image
image_manifest(
    name = "enhanced_image",
    base = ":converted_image",
    layers = [":new_layer"],
)

# Push using rules_img
image_push(
    name = "push",
    image = ":enhanced_image",
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag = "latest",
)

For multi-platform images, use image_index_from_oci_layout:

load("@rules_img//img:convert.bzl", "image_index_from_oci_layout")
load("@rules_img//img:media_types.bzl", "GZIP_LAYER")

image_index_from_oci_layout(
    name = "converted_multiarch",
    src = ":legacy_multiarch_image",
    layers = [GZIP_LAYER],
    manifests = ["linux/amd64", "linux/arm64"],
)

Container Structure Tests

Container structure tests verify the contents and configuration of your images.

See a working example in e2e/generic/integrationtest/container_structure_test.

rules_oci

load("@container_structure_test//:defs.bzl", "container_structure_test")
load("@rules_oci//oci:defs.bzl", "oci_load")

oci_load(
    name = "image_tarball",
    image = ":app_image",
    repo_tags = ["test:latest"],
)

container_structure_test(
    name = "structure_test",
    driver = "tar",
    image = ":image_tarball",
    configs = ["test_config.yaml"],
)

rules_img

Option 1: Using the Tar Driver (Doesn't require Docker daemon)

load("@container_structure_test//:defs.bzl", "container_structure_test")
load("@rules_img//img:load.bzl", "image_load")

image_load(
    name = "load_image",
    image = ":app_image",
    tag = "test:latest",
)

filegroup(
    name = "image_tarball",
    srcs = [":load_image"],
    output_group = "tarball",
)

container_structure_test(
    name = "structure_test",
    driver = "tar",
    image = ":image_tarball",
    configs = ["test_config.yaml"],
)

Option 2: Using the Docker Driver

load("@container_structure_test//:defs.bzl", "container_structure_test")

container_structure_test(
    name = "structure_test_docker",
    driver = "docker",
    image = ":app_image",
    configs = ["test_config.yaml"],
)

This approach requires Docker to be available and will load the image into the Docker daemon before testing.


Deployment

Pushing to Registries

rules_oci

load("@rules_oci//oci:defs.bzl", "oci_push")

oci_push(
    name = "push",
    image = ":app_image",
    repository = "ghcr.io/myorg/myapp",
    remote_tags = ["latest", "v1.0.0"],
)

rules_img

load("@rules_img//img:push.bzl", "image_push")

image_push(
    name = "push",
    image = ":app_image",
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag_list = ["latest", "v1.0.0"],
)

Key Changes:

  • oci_pushimage_push
  • repository parameter splits into registry and repository
  • repository_file in rules_oci → destination_file in rules_img (works the same way: file contains {registry}/{repository})
  • remote_tags in rules_oci takes a list of strings or a label (file) → in rules_img, there are separate attributes:
    • tag (string) and tag_list (list of string) allow setting tags in the build file
    • tag_file takes a file with newline-separated tags.

Run with:

bazel run //path/to:push

Pushing Multi-Platform Images

rules_oci

oci_push(
    name = "push_multiarch",
    image = ":app_multiarch",  # image_index
    repository = "ghcr.io/myorg/myapp",
    remote_tags = ["latest"],
)

rules_img

image_push(
    name = "push_multiarch",
    image = ":app_multiarch",  # image_index
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag = "latest",
)

Push with Single Tag

rules_oci

oci_push(
    name = "push",
    image = ":app_image",
    repository = "ghcr.io/myorg/myapp",
    remote_tags = ["v1.0.0"],
)

rules_img

image_push(
    name = "push",
    image = ":app_image",
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag = "v1.0.0",  # Use 'tag' for single tag
)

Push Strategies (rules_img Advantage)

rules_img offers advanced push strategies for better performance:

# Default: eager (downloads all layers locally, then pushes)
image_push(
    name = "push_eager",
    image = ":app_image",
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag = "latest",
    strategy = "eager",  # Default
)

# Lazy: streams from remote cache, skips existing blobs
image_push(
    name = "push_lazy",
    image = ":app_image",
    registry = "ghcr.io",
    repository = "myorg/myapp",
    tag = "latest",
    strategy = "lazy",  # Requires remote cache
)

Configure globally in .bazelrc:

# Use lazy push by default (requires remote cache)
common --@rules_img//img/settings:push_strategy=lazy

# Point to your remote cache
common --@rules_img//img/settings:remote_cache=grpcs://remote.buildbuddy.io

See the push strategies documentation for more details.

Loading into Docker/containerd

rules_oci

load("@rules_oci//oci:defs.bzl", "oci_load")

oci_load(
    name = "load",
    image = ":app_image",
    repo_tags = ["myapp:latest", "myapp:v1.0.0"],
)

rules_img

load("@rules_img//img:load.bzl", "image_load")

image_load(
    name = "load",
    image = ":app_image",
    tag_list = ["myapp:latest", "myapp:v1.0.0"],
)

Key Changes:

  • oci_loadimage_load
  • repo_tagstag_list

Run with:

bazel run //path/to:load

Incremental Loading (rules_img Advantage)

rules_img provides incremental loading when using containerd:

  • Only new/changed layers are transferred
  • Existing blobs are skipped automatically
  • Much faster for iterative development

Enable with:

image_load(
    name = "load",
    image = ":app_image",
    tag = "myapp:dev",
    daemon = "containerd",  # Use containerd directly
)

Or configure globally in .bazelrc:

common --@rules_img//img/settings:load_daemon=containerd

Complete Migration Example

Here's a complete before/after example showing a full application migration:

rules_oci (Before)

# MODULE.bazel
bazel_dep(name = "rules_oci", version = "2.0.0")
bazel_dep(name = "rules_pkg", version = "0.10.1")
bazel_dep(name = "rules_go", version = "0.58.3")

oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
oci.pull(
    name = "distroless_base",
    digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
    image = "gcr.io/distroless/base-debian12",
    platforms = ["linux/amd64", "linux/arm64"],
)
use_repo(oci, "distroless_base")

# BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_image_index", "oci_push", "oci_load")

go_binary(
    name = "server",
    srcs = ["main.go"],
)

pkg_tar(
    name = "server_layer",
    srcs = [":server"],
    package_dir = "/app",
)

oci_image(
    name = "server_image_amd64",
    base = "@distroless_base",
    tars = [":server_layer"],
    entrypoint = ["/app/server"],
    env = {"PORT": "8080"},
    target_compatible_with = ["@platforms//cpu:x86_64"],
)

oci_image(
    name = "server_image_arm64",
    base = "@distroless_base",
    tars = [":server_layer"],
    entrypoint = ["/app/server"],
    env = {"PORT": "8080"},
    target_compatible_with = ["@platforms//cpu:aarch64"],
)

oci_image_index(
    name = "server_multiarch",
    images = [
        ":server_image_amd64",
        ":server_image_arm64",
    ],
)

oci_push(
    name = "push",
    image = ":server_multiarch",
    repository = "ghcr.io/myorg/server",
    remote_tags = ["latest"],
)

oci_load(
    name = "load",
    image = ":server_image_amd64",
    repo_tags = ["server:dev"],
)

rules_img (After)

# MODULE.bazel
bazel_dep(name = "rules_img", version = "<version>")
bazel_dep(name = "rules_go", version = "0.58.3")

pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")

pull(
    name = "distroless_base",
    digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
    registry = "gcr.io",
    repository = "distroless/base-debian12",
)

# BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary")
load("@rules_img//img:image.bzl", "image_manifest", "image_index")
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:push.bzl", "image_push")
load("@rules_img//img:load.bzl", "image_load")

go_binary(
    name = "server",
    srcs = ["main.go"],
)

# Native image_layer (recommended)
image_layer(
    name = "server_layer",
    srcs = {
        "/app/server": ":server",
    },
)

# Single manifest definition
image_manifest(
    name = "server_image",
    base = "@distroless_base",
    layers = [":server_layer"],
    entrypoint = ["/app/server"],
    env = {"PORT": "8080"},
)

# Automatically builds for multiple platforms
image_index(
    name = "server_multiarch",
    manifests = [":server_image"],
    platforms = [
        "//platforms:linux_x86_64",
        "//platforms:linux_aarch64",
    ],
)

image_push(
    name = "push",
    image = ":server_multiarch",
    registry = "ghcr.io",
    repository = "myorg/server",
    tag = "latest",
)

image_load(
    name = "load",
    image = ":server_image",  # Can load multiarch too!
    tag = "server:dev",
)

Summary Checklist

Use this checklist to track your migration:

  • Update MODULE.bazel to use rules_img
  • Convert oci.pull() to pull() repository rules
  • Update base image references (split image into registry/repository)
  • Migrate layers:
    • Option 1: Use pkg_tar/tar targets directly in layers attribute
    • Option 2: Wrap with layer_from_tar for better control
    • Option 3: Migrate to image_layer (recommended)
  • Convert oci_image to image_manifest
    • Rename tarslayers
    • Rename workdirworking_dir
    • If env was a file label, use env_file instead
    • Move unsupported attributes to config_fragment if needed
  • Convert oci_image_index to image_index
  • Use output groups:
    • oci_layout (for OCI layout dir)
    • oci_tarball (for OCI layout tar)
    • tarball output group on image_load for Docker format
  • Update container structure tests (minimal changes)
  • Convert oci_push to image_push
    • Split repository into registry + repository
    • Rename remote_tagstag_list or tag
    • Consider using advanced push strategies
  • Convert oci_load to image_load
    • Rename repo_tagstag, tag_list or tag_file
    • Consider enabling incremental loading
  • Test your migration:
    • Build images: bazel build //path/to:image
    • Push images: bazel run //path/to:push
    • Load images: bazel run //path/to:load
    • Run structure tests: bazel test //path/to:structure_test

Additional Resources

Getting Help

If you encounter issues during migration:

  1. Check the examples directory for working patterns
  2. Review the API documentation
  3. Open an issue on GitHub

Happy migrating! 🚀