Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save brianjbayer/cd8fb8398ec901cca471841f66498da8 to your computer and use it in GitHub Desktop.

Select an option

Save brianjbayer/cd8fb8398ec901cca471841f66498da8 to your computer and use it in GitHub Desktop.
Create and use a custom reusable Dockerfile-based GitHub Action action

Developing and Reusing Dockerfile-based Actions in GitHub Actions

Nelsonville Brick Park - Brian J Bayer


GitHub Actions is powerful tool for automating workflows within the GitHub ecosystem such as Continuous Integration/Continuos Deployment (CI/CD).

One of its standout features is the ability to create custom actions that can be tailored to perform specific tasks. Among the different types of actions, Dockerfile-based actions provide a flexible and scalable approach to customize your workflows using your base image and language of choice.

In this post, we'll explore how to develop and reuse a Dockerfile-based GitHub Actions action, allowing you to encapsulate your logic in a Docker container. By leveraging Docker, we can ensure consistency across different environments, make the action portable, and reuse it in multiple workflows.

Whether you're creating a simple automation script or a complex CI/CD pipeline, Dockerfile-based actions can help you standardize and streamline your development process.


Creating a Dockerfile-based Action

To create Dockerfile-based action you need at least three things...

  1. A subdirectory for your action (e.g. ./.github/workflows/actions/delete-docker-hub-repository)
  2. A Dockerfile located in your action subdirectory that runs your task as an entrypoint
  3. An action.yml file containing the actions inputs including secrets and any outputs

GitHub Actions Dockerfile-based actions run by the convention of simply referencing the action's subdirectory and supplying any inputs and GitHub Actions will build the Dockerfile and run it, running your custom task in the container.

This leads to the fourth thing you will almost certainly need, your custom script for your task. This is what males the Dockerfile action so powerful and useful, because you supply the executing code, you can write it in any language and use any base image that you like.

However, Dockerfile-based action build and run the image every time they are called, so smaller and simpler images are preferred.

Finally the fifth thing you should include is a README.md for your action documenting what it does and how to use it and test it.

And because your action is Docker based, you can use containerized development with nothing more than Docker and your favorite editor or IDE.

While this is an example of a Dockerfile-based action that uses a Ruby script to delete a list of Docker Hub image repositories, it shows you the general approach to developing these types of actions:

  1. Create the subdirectory for your action
  2. Create the Dockerfile and containerized development environment
  3. Develop your custom script for your task
  4. Create the actions.yml

Create the Subdirectory for Your Action

In your GitHub repository, create the subdirectory for your action, for example...

mkdir -p ./.github/workflows/actions/delete-docker-hub-repository

Create the Dockerfile and Containerized Development Environment

You just need the name of your custom script at this point as the Dockerfile action pattern is a simple Dockerfile that copies your script into the image and runs it as the entrypoint.

  1. Create the file for your custom script in the action's subdirectory, for example...

    touch ./.github/workflows/actions/delete-docker-hub-repository/delete-docker-hub-repository.rb
  2. Create your Dockerfile in the action's subdirectory that copies and runs your custom script as the entrypoint, for example...

    #--- Base Image ---
    ARG BASE_IMAGE=ruby:3.4.6-slim-trixie
    FROM ${BASE_IMAGE} AS ruby-base
    
    #--- Run Action Stage ---
    # Make the entrypoint script executable
    COPY --chmod=0755 delete-docker-hub-repository.rb /delete-docker-hub-repository.rb
    
    # Set the entrypoint to the script
    ENTRYPOINT ["/delete-docker-hub-repository.rb"]

    πŸ’‘ The examples and references usually have an alpine base image, but a debian-based slim is not that much bigger, is more robust, and runs bash by default

    βš™οΈ COPY --chmod=0755 eliminates the added layer to RUN a separate chmod command to make your script executable

Build and Run Your Image as a Containerized Development Environment

You can now use your Dockerfile to develop your script.

  1. Change to your action's subdirectory, for example...

    cd ./.github/workflows/actions/delete-docker-hub-repository
  2. Build your image (even though it does not really have a script yet), for example...

    docker build --no-cache -t delete-docker-hub-repository .
    
  3. Run a containerized development environment for developing your script by running your image interactively and volume-mounting your working directory (you will need to override the entrypoint which is your script) for example...

    docker run --rm -it -v $(pwd):/app -w /app --entrypoint 'bash' delete-docker-hub-repository
    

    Your entrypoint working directory is at the container root directory i.e. /, but you can not volume mount over it, so you need to volume mount to a different directory and set it as the working directory

    πŸ’‘ if using an alpine-based image, be sure to use sh as your entrypoint command

Develop Your Custom Script For Your Action

Develop your script in the action's subdirectory and run and test it in the running container.

  • Use environment variables for your inputs
  • Write to the special GitHub Actions $GITHUB_OUTPUT environment file for outputs

Inputs Using Environment Variable

The action in this example will have three inputs. For example, using environment variables in a Ruby script for these inputs...

docker_hub_username = ENV['DOCKER_HUB_USERNAME']
docker_hub_password = ENV['DOCKER_HUB_PASSWORD']
docker_hub_repository_name = ENV['DOCKER_HUB_REPOSITORY']

The action.yml file will map each declared input to its corresponding environment variable, making them available inside the container and your script at runtime.

Example Action Ruby Script

Here is a full example of a custom action script written in Ruby that deletes a Docker Hub image repository.

Example Ruby Script for Docker Hub Repository Deletion
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'net/http'
require 'uri'
require 'json'

SUCCESS_STATUS_CODE = 200
ACCEPTED_STATUS_CODE = 202
NOT_FOUND_STATUS_CODE = 404

docker_hub_username = ENV['DOCKER_HUB_USERNAME']
docker_hub_password = ENV['DOCKER_HUB_PASSWORD']
docker_hub_repository_name = ENV['DOCKER_HUB_REPOSITORY']

if docker_hub_username.nil? || docker_hub_password.nil? || docker_hub_repository_name.nil?
  warn 'Missing necessary environment variables!'
  exit 1
end

# Docker Hub login API endpoint
login_url = URI('https://hub.docker.com/v2/users/login/')

login_payload = {
  username: docker_hub_username,
  password: docker_hub_password
}

# Get the authentication token
uri = URI.parse(login_url.to_s)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' })
request.body = login_payload.to_json

response = http.request(request)

if response.code.to_i == SUCCESS_STATUS_CODE
  response_body = JSON.parse(response.body)
  auth_token = response_body['token']
  warn 'Successfully authenticated'
else
  warn "Error authenticating: #{response.body}"
  exit 2
end

# Check if the repository exists
check_url = URI("https://hub.docker.com/v2/repositories/#{docker_hub_username}/#{docker_hub_repository_name}/")
warn "Checking existence of repository: #{check_url}"

# GET the repository
uri = URI.parse(check_url.to_s)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri.path)

# Send the request to Docker Hub API
check_response = http.request(request)
check_response_code = check_response.code.to_i

if check_response_code == SUCCESS_STATUS_CODE
  warn "#{check_response_code}: Repository '#{docker_hub_repository_name}' exists"
elsif check_response_code == NOT_FOUND_STATUS_CODE
  warn "#{check_response_code}: Error Repository '#{docker_hub_repository_name}' not found"
  exit 2
else
  warn "#{check_response_code}: Error checking repository: #{check_response.body}"
  exit 2
end

# Delete the repository
delete_url = URI("https://hub.docker.com/v2/repositories/#{docker_hub_username}/#{docker_hub_repository_name}/")
warn "Deleting: #{delete_url}"

# Create DELETE request to remove the repository
uri = URI.parse(delete_url.to_s)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri.path, { 'Authorization' => "JWT #{auth_token}" })

# Send the request to Docker Hub API
delete_response = http.request(request)

delete_response_code = delete_response.code.to_i
delete_response_body = delete_response.body

if delete_response_code == ACCEPTED_STATUS_CODE
  warn "#{delete_response_code}: Repository '#{docker_hub_repository_name}' has been deleted successfully"
else
  warn "#{delete_response_code}: Failed to delete repository: #{delete_response_body}"
  exit 2
end

Outputs

For any outputs, your script needs to write to the special GitHub Actions environment variable $GITHUB_OUTPUT.

Here are some examples...

Writing to the action output my-output in bash...

my_output='something useful'

# Write to the GitHub Actions output
echo "my-output=$my_output" >> $GITHUB_OUTPUT

Or writing to the action output my-output in Ruby...

my_output = 'something useful'

# Write to the GitHub Actions output
File.open(ENV['GITHUB_OUTPUT'], 'w') do |file|
  file.puts "my-output=#{my_output}"  # You can replace key=value with the actual key-value pair
end

Create your action.yml File

Now that you have your action's Dockerfile and custom script, you can create the action.yml metadata file to connect your Dockerfile and custom script to GitHub Actions.

Here you define your inputs, linking them to your environment variables in your custom script, your outputs, and that this is a Dockerfile-based action.

For example, this is the action.yml for the action to delete a DockerHub image repository...

name: Delete Docker Hub Repository
description: "Deletes the specified Docker Hub Repository"

inputs:
  docker-hub-repository:
    description: "Docker Hub Username"
    required: true
  docker-hub-username:
    description: "Docker Hub Username"
    required: true
  docker-hub-password:
    description: "Docker Hub Password"
    required: true

runs:
  using: docker
  image: Dockerfile
  env:
    DOCKER_HUB_REPOSITORY: ${{ inputs.docker-hub-repository }}
    DOCKER_HUB_USERNAME: ${{ inputs.docker-hub-username }}
    DOCKER_HUB_PASSWORD: ${{ inputs.docker-hub-password }}

The Action's inputs which correspond to the environment variables in the custom script are defined in the block...

inputs:
  docker-hub-repository:
    description: "Docker Hub Username"
    required: true
  docker-hub-username:
    description: "Docker Hub Username"
    required: true
  docker-hub-password:
    description: "Docker Hub Password"
    required: true

πŸ‘€ Secrets such as the docker-hub-username and docker-hub-password are passed as action inputs, BUT are recognized and handled securely as secrets by GitHub Actions.

Any outputs would be defined similarly at this yaml level...

outputs:
  my-output: # id of output
    description: 'Something awesome'

The runs block defines your action as a Dockerfile-based action...

runs:
  using: docker
  image: Dockerfile

This is also where you pass the action inputs as environment variables to the running container which contains your custom script...

runs:
...
  env:
    DOCKER_HUB_REPOSITORY: ${{ inputs.docker-hub-repository }}
    DOCKER_HUB_USERNAME: ${{ inputs.docker-hub-username }}
    DOCKER_HUB_PASSWORD: ${{ inputs.docker-hub-password }}

Using Your Dockerfile-Based Action

Now that you have created your action, you can use it in your Github Actions workflows. However, this topic is where the Docker documentation (and AI responses) are lacking and incomplete.

Git Checkout Your Action

This may be obvious, but since your action is in "code" instead of a simple workflow YAML (configuration) file, you must git checkout the repository (and reference) that contains your action.

πŸ’‘ This git checkout is required even if you are calling youraction from the same repository it is in.

For example, this actual workflow job calling an action in the same repository (uses: ./.github/workflows/actions/delete-docker-hub-repository)...

jobs:

  test-delete-docker-hub-repository-action:
    name: Test Delete Docker Hub Repository Action
    runs-on: ubuntu-latest
    steps:
      - name: Delete Docker Repos
        uses: ./.github/workflows/actions/delete-docker-hub-repository
        with:
          docker-hub-repository: "sample-login-watir-cucumber_add-parallel"
          docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }}
          docker-hub-password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

Produces this error...

Error: Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '/home/runner/work/actions-image-cicd/actions-image-cicd/.github/workflows/actions/delete-docker-hub-repository'. Did you forget to run actions/checkout before running your local action?

Git Checkout Your Local Action

To use your action from the same repository, add a checkout step to the job calling your local action...

- uses: actions/checkout@v6

✨ It is common practice to use the official action actions/checkout to checkout your repository in GitHub Actions

For example to fix, the failing workflow above...

jobs:

  test-delete-docker-hub-repository-action:
    name: Test Delete Docker Hub Repository Action
    runs-on: ubuntu-latest
    steps:
      # Checkout this repository with the action to be called
      - uses: actions/checkout@v6

      - name: Delete Docker Repos
        uses: ./.github/workflows/actions/delete-docker-hub-repository
        with:
          docker-hub-repository: "sample-login-watir-cucumber_add-parallel"
          docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }}
          docker-hub-password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

Git Checkout Your Actions From Another Repository

If you want to use your Dockerfile-based action in another repository making it truly reusable, you must also add a git checkout step in your calling job, but you must...

  • Explicitly specify the repository with your action

    • πŸ” if your action is in a private repository, you must also add authorization

      ✨ It is common practice to use the offical action actions/create-github-app-token to generate a one-time access token to checkout your repository in GitHub Actions

  • Consider checkout order and location if checking out multiple repositories

    • πŸ’‘ It is generally a best-fit practice to checkout any workflow code to its own unique subdirectory and after any source repositories are checked out to the workflow runner current directory
    • Later checkouts will overwrite previous checkouts if not made to a unique subdirectory

When to Create a Wrapper Workflow For Your Action

To make it even easier to reuse your Dockerfile-based action, you may want to "wrap" it in a Reusable Workflow. This allows you to encapsulate the git checkout and any other setup and/or any other common processing related to your action.

However, a wrapper workflow is generally not recommended if your action is intended to process or needs data or files created in a previous step within the same job in other workflows. These ephemeral artifacts would not be available to the action in the wrapper.

The example action presented here does not require any artifacts from a prior step so a wrapper workflow makes sense.

Here is a wrapper Reusable Workflow for the example action that deletes a Docker Hub image repository. Tt contains the git checkout of the Dockerfile-based action as well as an example of creating a Summary Report...

πŸ’‘ Note the ref input which allows you to specify a version (as a git reference) for your called action

name: Delete Image Repositories

on:
  workflow_call:
    inputs:
      runner:
        description: "The type of runner for this workflow (Default: ubuntu-latest)"
        required: false
        type: string
        default: ubuntu-latest
      repositories:
        description: "Docker Hub repositories to delete (JSON array)"
        required: true
        type: string
      summary:
        description: "Add a summary report to the workflow run (Default: true)"
        required: false
        type: boolean
        default: true
      ref:
        description: "The git reference for the action"
        required: false
        type: string

    secrets:
      registry_u:
        description: The username for the docker login
        required: true
      registry_p:
        description: The password (PAT) for the docker login
        required: true

jobs:
  delete-image-repository:
    name: Delete ${{ matrix.repository }}
    runs-on: ${{ inputs.runner }}

    strategy:
      fail-fast: false
      matrix:
        repository: ${{ fromJSON(inputs.repositories) }}

    steps:
      - name: Checkout action repository
        uses: actions/checkout@v5
        with:
          repository: brianjbayer/actions-image-cicd
          ref: ${{ inputs.ref }}
          path: action-repo

      # THe Docker Hub API needs just the repository name, not the namespace/org
      - name: Extract Hub repository
        id: extract-repo-name
        run: |
          repo="${{ matrix.repository }}"
          hub_repo="${repo##*/}"
          echo "hub_repo=$hub_repo" >> "$GITHUB_OUTPUT"
          echo "hub_repo: [$hub_repo]"

      - name: Delete Hub repository ${{ steps.extract-repo-name.outputs.hub_repo }}
        id: delete-docker-repos
        uses: ./action-repo/.github/actions/delete-docker-hub-repository
        with:
          docker-hub-repository: ${{ steps.extract-repo-name.outputs.hub_repo }}
          docker-hub-username: ${{ secrets.registry_u }}
          docker-hub-password: ${{ secrets.registry_p }}

      - name: Summary report
        if: ${{ inputs.summary == true && (success() || failure()) }}
        run: |
          if [[ ${{ steps.delete-docker-repos.outcome }} == 'success' ]] ; then
            message=":white_check_mark: Deleted Docker Hub repository \`${{ matrix.repository }}\`"
          else
            message=":x: Failed to delete Docker Hub repository \`${{ matrix.repository }}\`"
          fi
          cat <<EOF >> $GITHUB_STEP_SUMMARY
          ### Docker Hub Repository Deletion
          Repository: \`${{ matrix.repository }}\`

          ${message}

          EOF

:octocat: You can find the example Dockerfile-based action at brianjbayer/actions-image-cicd as well as the wrapper Reusable workflow


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment