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.

Revisions

  1. brianjbayer created this gist Dec 8, 2025.
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,575 @@
    # Developing and Reusing Dockerfile-based Actions in GitHub Actions
    ![Nelsonville Brick Park - Brian J Bayer](https://lh3.googleusercontent.com/pw/AP1GczMfx1XqZEsh4iZcrAyFTj7us5vAZAVks3kdQieT7jiaVGaVW2OgiaP6OyuDH3NgaeSF59qS8phk5obyrKMxW2_52vVuc3kRBkKzvKzv5Wav4nCd_klju0uDEmtVsdiPChc5K9JnK0rJInSe-2NxTSCrUw=w2024-h1520-s-no-gm)

    ---

    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 `action`s
    that can be tailored to perform specific tasks. Among the different types
    of `action`s, `Dockerfile`-based `action`s 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 `action`s 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` `input`s including `secret`s
    and any `output`s

    GitHub Actions `Dockerfile`-based `action`s 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 `action`s:

    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...
    ```bash
    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...
    ```bash
    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...

    ```dockerfile
    #--- 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"]
    ```

    > :bulb: 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
    > :gear: `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...
    ```bash
    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

    > :bulb: 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 `input`s
    * Write to the special GitHub Actions `$GITHUB_OUTPUT` environment file
    for `output`s

    #### Inputs Using Environment Variable
    The `action` in this example will have three `input`s. For example,
    using environment variables in a Ruby script for these inputs...

    ```ruby
    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.

    <details>
    <summary>Example Ruby Script for Docker Hub Repository Deletion</summary>

    ```ruby
    #!/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
    ```
    </details>

    #### 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`...
    ```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...
    ```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...
    ```yaml
    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 `input`s which correspond to the environment
    variables in the custom script are defined in the block...
    ```yaml
    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
    ```

    :eyes: 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...
    ```yaml
    outputs:
    my-output: # id of output
    description: 'Something awesome'
    ```

    The `runs` block defines your `action` as a `Dockerfile`-based
    `action`...
    ```yaml
    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...
    ```yaml
    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](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-a-docker-container-action#example-using-a-public-action)
    (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`.

    :bulb: This `git checkout` is required **even if you are calling
    your`action` 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`)...

    ```yaml
    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`...
    ```yaml
    - uses: actions/checkout@v6
    ```

    > :sparkles: It is common practice to use the official `action`
    > [`actions/checkout`](https://github.com/actions/checkout)
    > to checkout your repository in GitHub Actions
    For example to fix, the failing workflow above...
    ```yaml
    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`
    * :closed_lock_with_key: if your `action` is in a private
    repository, you must also add authorization

    > :sparkles: It is common practice to use the offical `action`
    > [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token?tab=readme-ov-file#use-app-token-with-actionscheckout)
    > to generate a one-time access token to checkout your repository
    > in GitHub Actions

    * Consider checkout order and location if checking out
    multiple repositories
    * :bulb: 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](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows).
    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*](https://github.blog/news-insights/product-news/supercharging-github-actions-with-job-summaries/)...

    > :bulb: Note the `ref` `input` which allows you to specify a
    > version (as a `git` reference) for your called `action`

    ```yaml
    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](https://github.com/brianjbayer/actions-image-cicd/tree/main/.github/actions/delete-docker-hub-repository)
    > as well as the wrapper
    > [Reusable workflow](https://github.com/brianjbayer/actions-image-cicd/blob/main/.github/workflows/delete_docker_hub_repositories.yml)

    ---