Skip to content

upgrade version when available (https://github.com/docker/buildx/releases) #690

@github-actions

Description

@github-actions

# FIXME: upgrade version when available (https://github.com/docker/buildx/releases)

---
name: "Docker - Setup"
description: |
  Shared action to configure Docker tooling and OCI registry authentication.
author: hoverkraft
branding:
  icon: package
  color: blue

inputs:
  oci-registry:
    description: |
      OCI registry configuration used to pull, push and cache images.
      Accepts either a registry hostname string (default format) or a JSON object.
      JSON example: `{"pull":"docker.io","pull:private":"ghcr.io","push":"ghcr.io"}`
    default: "ghcr.io"
    required: true
  oci-registry-username:
    description: |
      Username configuration used to log against OCI registries.
      Accepts either a single username string (default format) or a JSON object using the same keys as `oci-registry`.
    required: false
  oci-registry-password:
    description: |
      Password or personal access token configuration used to log against OCI registries.
      Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`.
    required: false
  built-images:
    description: |
      Optional built images payload used to resolve manifest publication registries.
      When provided, registry authentication targets are inferred from the built image data.
    required: false
  setup-buildx:
    description: |
      Whether to install and configure Docker Buildx.
    default: true
    required: false

outputs:
  push-registry:
    description: "Registry used for published images/manifests."
    value: ${{ steps.resolve-oci-registries.outputs.push-registry }}
  cache-registry:
    description: "Registry used for registry-backed build cache."
    value: ${{ steps.resolve-oci-registries.outputs.cache-registry }}
  pull-registries:
    description: "JSON array of registries used to pull base images."
    value: ${{ steps.resolve-oci-registries.outputs.pull-registries }}
  buildx-name:
    description: "Docker Buildx builder name."
    value: ${{ steps.setup-buildx.outputs.name }}

runs:
  using: "composite"
  steps:
    - id: resolve-oci-registries
      uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
      env:
        BUILT_IMAGES_INPUT: ${{ inputs.built-images }}
        REGISTRY_INPUT: ${{ inputs.oci-registry }}
        REGISTRY_PASSWORD_INPUT: ${{ inputs.oci-registry-password }}
        REGISTRY_USERNAME_INPUT: ${{ inputs.oci-registry-username }}
      with:
        script: |
          function parseJsonObjectInput(inputName, rawValue) {
            const value = `${rawValue}`.trim();
            if (!value.length) {
              return null;
            }

            if (!value.startsWith('{')) {
              return value;
            }

            let parsedValue;
            try {
              parsedValue = JSON.parse(value);
            } catch (error) {
              throw new Error(`"${inputName}" input is not a valid JSON object: ${error}`);
            }

            if (parsedValue === null || Array.isArray(parsedValue) || typeof parsedValue !== 'object') {
              throw new Error(`"${inputName}" input must be a string or a JSON object`);
            }

            return parsedValue;
          }

          function normalizeString(value, fieldName) {
            if (typeof value !== 'string') {
              throw new Error(`"${fieldName}" must be a string`);
            }

            const trimmedValue = value.trim();
            if (!trimmedValue.length) {
              throw new Error(`"${fieldName}" must not be empty`);
            }

            return trimmedValue;
          }

          function isPullRole(role) {
            return role === 'pull' || role.startsWith('pull:');
          }

          function normalizeRoleMapInput(inputName, rawValue) {
            const parsedValue = parseJsonObjectInput(inputName, rawValue);

            if (parsedValue === null) {
              return {};
            }

            if (typeof parsedValue === 'string') {
              return { scalar: normalizeString(parsedValue, inputName) };
            }

            return Object.entries(parsedValue).reduce((roleMap, [key, value]) => {
              if (key !== 'push' && key !== 'cache' && !isPullRole(key)) {
                throw new Error(`"${inputName}.${key}" is not supported`);
              }

              roleMap[key] = normalizeString(value, `${inputName}.${key}`);
              return roleMap;
            }, {});
          }

          function resolveCredentialByRole(credentialMap, role, registry, pushRegistry) {
            const defaultCredential = credentialMap.scalar ?? '';

            if (role === 'push') {
              return credentialMap.push ?? defaultCredential;
            }

            if (role === 'cache') {
              return credentialMap.cache ?? credentialMap.push ?? defaultCredential;
            }

            if (!isPullRole(role)) {
              return defaultCredential;
            }

            if (credentialMap[role] !== undefined) {
              return credentialMap[role];
            }

            if (credentialMap.pull !== undefined) {
              return credentialMap.pull;
            }

            if (registry === pushRegistry && credentialMap.push !== undefined) {
              return credentialMap.push;
            }

            return defaultCredential;
          }

          function resolvePushCredential(credentialMap) {
            return credentialMap.push ?? credentialMap.scalar ?? '';
          }

          function extractRegistryFromBuiltImage(builtImage) {
            if (builtImage?.registry) {
              return normalizeString(
                builtImage.registry,
                `built-images.${builtImage.name || 'unknown'}.registry`,
              );
            }

            const imageReference = builtImage?.images?.[0];
            if (typeof imageReference === 'string') {
              const match = imageReference.trim().match(/^([^\/]+)\//);
              if (match) {
                return match[1];
              }
            }

            return '';
          }

          function createRegistryAuthList(registryLogins) {
            return registryLogins.reduce((registryAuthList, registryLogin) => {
              const { registry, username, password, required } = registryLogin;

              if ((username && !password) || (!username && password)) {
                throw new Error(`Credentials for registry "${registry}" must define both username and password`);
              }

              if (!username && !password) {
                if (required) {
                  throw new Error(`Credentials for registry "${registry}" are required`);
                }

                core.info(`Skipping Docker login for optional registry "${registry}" because no credentials were provided.`);
                return registryAuthList;
              }

              registryAuthList.push({
                registry,
                username,
                password,
              });

              return registryAuthList;
            }, []);
          }

          function toRegistryAuthYaml(registryAuthList) {
            return registryAuthList
              .map(registryAuth => {
                const lines = [
                  `- registry: ${JSON.stringify(registryAuth.registry)}`,
                  `  username: ${JSON.stringify(registryAuth.username)}`,
                  `  password: ${JSON.stringify(registryAuth.password)}`,
                ];

                return lines.join('\n');
              })
              .join('\n');
          }

          function setOutputs({ pushRegistry = '', cacheRegistry = '', pullRegistries = [], registryAuth = [] }) {
            core.setOutput('push-registry', pushRegistry);
            core.setOutput('cache-registry', cacheRegistry);
            core.setOutput('pull-registries', JSON.stringify(pullRegistries));
            core.setOutput('registry-auth', toRegistryAuthYaml(registryAuth));
            core.setOutput('has-registry-auth', registryAuth.length ? 'true' : 'false');
          }

          const registryInputName = ['oci', 'registry'].join('-');
          const registryUsernameInputName = [registryInputName, 'username'].join('-');
          const registryPasswordInputName = [registryInputName, 'password'].join('-');

          const builtImagesInput = `${process.env.BUILT_IMAGES_INPUT || ''}`.trim();
          if (builtImagesInput.length) {
            let builtImages;
            try {
              builtImages = JSON.parse(builtImagesInput);
            } catch (error) {
              throw new Error(`"built-images" input is not a valid JSON: ${error}`);
            }

            const registries = [
              ...new Set(
                Object.values(builtImages)
                  .map(extractRegistryFromBuiltImage)
                  .filter(Boolean),
              ),
            ];

            if (!registries.length) {
              const registryInput = normalizeRoleMapInput(registryInputName, `${process.env.REGISTRY_INPUT || ''}`);
              if (registryInput.scalar) {
                registries.push(registryInput.scalar);
              } else {
                const [, firstPullRegistryValue] = Object.entries(registryInput).find(([key]) => isPullRole(key)) ?? [];
                const pushRegistry = registryInput.push ?? registryInput.cache ?? firstPullRegistryValue ?? '';
                if (!pushRegistry.length) {
                  throw new Error('Unable to resolve any OCI registry to authenticate against');
                }

                registries.push(pushRegistry);
              }
            }

            const usernameByRole = normalizeRoleMapInput(registryUsernameInputName, `${process.env.REGISTRY_USERNAME_INPUT || ''}`);
            const passwordByRole = normalizeRoleMapInput(registryPasswordInputName, `${process.env.REGISTRY_PASSWORD_INPUT || ''}`);
            const username = resolvePushCredential(usernameByRole);
            const password = resolvePushCredential(passwordByRole);

            const registryAuth = createRegistryAuthList(
              registries.map(registry => ({
                registry,
                username,
                password,
                required: true,
              })),
            );

            setOutputs({
              pushRegistry: registries[0] ?? '',
              cacheRegistry: registries[0] ?? '',
              registryAuth,
            });
            return;
          }

          const registryInput = normalizeRoleMapInput(registryInputName, `${process.env.REGISTRY_INPUT || ''}`);

          let pushRegistry = '';
          let cacheRegistry = '';
          let hasExplicitCacheRegistry = false;
          let pullRegistryEntries = [];
          let pullRegistries = [];

          if (registryInput.scalar) {
            pushRegistry = registryInput.scalar;
            cacheRegistry = pushRegistry;
            pullRegistries = [pushRegistry];
          } else {
            pushRegistry = registryInput.push ?? '';
            hasExplicitCacheRegistry = typeof registryInput.cache === 'string';
            cacheRegistry = registryInput.cache ?? pushRegistry;
            pullRegistryEntries = Object.entries(registryInput)
              .filter(([key]) => isPullRole(key))
              .map(([role, registry]) => ({ role, registry }));

            if (!pushRegistry.length) {
              throw new Error(`"oci-registry.push" is required when "oci-registry" uses the JSON object format`);
            }

            if (!pullRegistryEntries.length) {
              pullRegistryEntries = [{ role: 'pull', registry: pushRegistry }];
            }

            pullRegistries = pullRegistryEntries.map(({ registry }) => registry);
          }

          const registryEntries = [
            { role: 'push', registry: pushRegistry, required: true },
            ...pullRegistryEntries.map(pullRegistryEntry => ({ ...pullRegistryEntry, required: false })),
          ];

          if (hasExplicitCacheRegistry) {
            registryEntries.push({ role: 'cache', registry: cacheRegistry, required: true });
          }

          const usernameByRole = normalizeRoleMapInput(registryUsernameInputName, `${process.env.REGISTRY_USERNAME_INPUT || ''}`);
          const passwordByRole = normalizeRoleMapInput(registryPasswordInputName, `${process.env.REGISTRY_PASSWORD_INPUT || ''}`);

          const registryLoginsByRegistry = new Map();
          for (const registryEntry of registryEntries) {
            const { role, registry, required } = registryEntry;
            const username = resolveCredentialByRole(usernameByRole, role, registry, pushRegistry);
            const password = resolveCredentialByRole(passwordByRole, role, registry, pushRegistry);

            const existingRegistryLogin = registryLoginsByRegistry.get(registry);
            if (existingRegistryLogin) {
              const hasDifferentUsername = existingRegistryLogin.username && username && existingRegistryLogin.username !== username;
              const hasDifferentPassword = existingRegistryLogin.password && password && existingRegistryLogin.password !== password;
              if (hasDifferentUsername || hasDifferentPassword) {
                throw new Error(`Conflicting credentials configured for registry "${registry}"`);
              }

              if (!existingRegistryLogin.username && username) {
                existingRegistryLogin.username = username;
              }

              if (!existingRegistryLogin.password && password) {
                existingRegistryLogin.password = password;
              }
              existingRegistryLogin.required ||= required;
              continue;
            }

            registryLoginsByRegistry.set(registry, {
              registry,
              username,
              password,
              required,
            });
          }

          const registryAuth = createRegistryAuthList([...registryLoginsByRegistry.values()]);
          setOutputs({
            pushRegistry,
            cacheRegistry,
            pullRegistries,
            registryAuth,
          });

    - id: detect-docker
      uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
      with:
        script: |
          const dockerPath = await io.which('docker', false);
          core.setOutput('exists', dockerPath ? 'true' : 'false');

    - if: steps.detect-docker.outputs.exists != 'true'
      uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5.0.0

    - if: inputs.setup-buildx != 'false'
      uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
      id: setup-buildx
      with:
        # FIXME: upgrade version when available (https://github.com/docker/buildx/releases)
        version: v0.31.1
        # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit)
        driver-opts: |
          image=moby/buildkit:v0.27.0

    - if: steps.resolve-oci-registries.outputs.has-registry-auth == 'true'
      uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
      with:
        registry-auth: ${{ steps.resolve-oci-registries.outputs.registry-auth }}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions