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.
To create Dockerfile-based action you need at least three things...
- A subdirectory for your
action(e.g../.github/workflows/actions/delete-docker-hub-repository) - A
Dockerfilelocated in youractionsubdirectory that runs your task as an entrypoint - An
action.ymlfile containing theactionsinputs includingsecrets and anyoutputs
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:
- Create the subdirectory for your
action - Create the
Dockerfileand containerized development environment - Develop your custom script for your task
- Create the
actions.yml
In your GitHub repository, create the subdirectory for your action,
for example...
mkdir -p ./.github/workflows/actions/delete-docker-hub-repositoryYou 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.
-
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
-
Create your
Dockerfilein theaction'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
alpinebase image, but a debian-basedslimis not that much bigger, is more robust, and runsbashby defaultβοΈ
COPY --chmod=0755eliminates the added layer toRUNa separatechmodcommand to make your script executable
You can now use your Dockerfile to develop your script.
-
Change to your
action's subdirectory, for example...cd ./.github/workflows/actions/delete-docker-hub-repository -
Build your image (even though it does not really have a script yet), for example...
docker build --no-cache -t delete-docker-hub-repository . -
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-repositoryYour 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 useshas your entrypoint command
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_OUTPUTenvironment file foroutputs
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.
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
endFor 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_OUTPUTOr 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
endNow 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: DockerfileThis 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 }}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.
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?
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
actionactions/checkoutto 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 }}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
actionis in a private repository, you must also add authorization⨠It is common practice to use the offical
actionactions/create-github-app-tokento 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
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
refinputwhich allows you to specify a version (as agitreference) for your calledaction
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
You can find the example
Dockerfile-basedactionat brianjbayer/actions-image-cicd as well as the wrapper Reusable workflow