Skip to content

Composite Actions


check-certs.yml now does everything you set out to build: it checks certificates, and it emails you when one needs attention. But look at its steps again — “Setup Python” and “Check certificates” are two steps that only ever appear together, in that order, with no reason to ever run one without the other. Let’s pack those steps properly.

What’s Difference From Previous Project’s Composite Action?

The composite action from previous project (Building CI Pipelines) solved a small, specific problem: a couple of steps that were getting copy-pasted, bundled up so you didn’t have to retype them. What you’re building now is structurally the same idea — runs: using: composite — but aimed at something bigger.

This action is going to declare real inputs, produce a real output, and be built so that dropping it into a completely different job (or, in principle, a different repository) means handing it two values and reading one back — nothing about its internals needs to leak out. That’s the difference between “steps I didn’t want to retype” and “a small tool with a contract.”

It’s more of “Reusability” but what we have done till now is also more than enough which is now more polished. But this “polished” may be the subject to more “complexity” in some organizations and that’s fine — follow whatever best suits you and your organization. Take this as another approach of improving workflow and not any hard and fast rule towards perfection.

Building the Composite Action

Local composite actions live in their own folder, each with an action.yml at its root — never loose inside .github/workflows/, which is reserved for actual workflow files. Create .github/actions/ssl-cert-monitor/action.yml:

mkdir -p .github/actions/ssl-cert-monitor
touch .github/actions/ssl-cert-monitor/action.yml
.github/actions/ssl-cert-monitor/action.yml
name: 'SSL Cert Expiry Monitor'
description: 'Checks TLS certificate expiry for one or more hostnames and reports a coarse status.'

inputs:
  domains:
    description: 'Hostnames to check, one per line'
    required: true
  warning-days:
    description: 'Warn when a certificate expires within this many days'
    required: false
    default: '30'

outputs:
  status:
    description: 'ok, issue, or unreachable — a coarse summary of the check. See the step logs for full per-host detail.'
    value: ${{ steps.run-check.outputs.status }}

runs:
  using: 'composite'
  steps:
    - name: Setup Python
      uses: actions/setup-python@v7
      with:
        python-version: '3.13'

    - name: Run certificate check
      id: run-check
      shell: bash
      run: |
        set +e
        echo "${{ inputs.domains }}" | python3 ssl_cert_checker.py --stdin --days ${{ inputs.warning-days }}
        exit_code=$?
        set -e

        case $exit_code in
          0) status=ok ;;
          1) status=issue ;;
          2) status=unreachable ;;
          *) status=unknown ;;
        esac
        echo "status=$status" >> "$GITHUB_OUTPUT"

        exit $exit_code

A few things here that don’t come up in a normal job’s steps: list:

  • Every run step needs an explicit shell:. In a regular job, the runner picks a sensible default shell for you. Inside a composite action, there’s no such default — leave shell: off and the action simply fails to run. bash is the right choice on the ubuntu-latest runner you’ve been using all course.
  • The exit code has to be captured, not swallowed. ssl_cert_checker.py exiting 1 or 2 is exactly what should mark this step (and the job) as failed — that’s the whole exit code contract from the setup chapter. But you also want to read that code and turn it into a friendlier status output before the step ends. set +e / set -e brackets the one command allowed to fail without killing the script early; capturing $? immediately after it runs is what lets you have both — a real output and a real failure.
  • The status output only exists because run-check set it. steps.run-check.outputs.status inside the top-level outputs: block is how a composite action exposes something one of its internal steps produced, using the same id: pattern you already use to reference steps within a job.

Note

This action assumes ssl_cert_checker.py is sitting in the repository that calls it — which is true here, since the action and the script live in the same repo. That’s a real limitation on how portable it currently is: a different repository could call this action by path, but it would need its own copy of ssl_cert_checker.py checked out first. Chapter 43 removes that limitation by bundling the script into the action itself.

Calling It From the Reusable Workflow

Update check-certs.yml to call the composite action instead of running its two steps inline:

.github/workflows/check-certs.yml
name: Check Certificates

on:
  workflow_call:
    inputs:
      domains:
        description: 'Hostnames to check, one per line'
        required: true
        type: string
      warning-days:
        description: 'Warn when a certificate expires within this many days'
        required: false
        type: string
        default: "30"
    secrets:
      gmail_username:
        description: 'Gmail address to send alerts from'
        required: true
      gmail_app_password:
        description: 'Gmail App Password for that account'
        required: true

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Check certificates
        id: monitor
        uses: ./.github/actions/ssl-cert-monitor
        with:
          domains: ${{ inputs.domains }}
          warning-days: ${{ inputs.warning-days }}

      - name: Email alert
        if: failure()
        uses: dawidd6/action-send-mail@v17
        with:
          server_address: smtp.gmail.com
          server_port: 465
          username: ${{ secrets.gmail_username }}
          password: ${{ secrets.gmail_app_password }}
          subject: 'Certificate alert: action needed'
          to: ${{ secrets.gmail_username }}
          from: 'SSL Cert Checker <${{ secrets.gmail_username }}>'
          body: |
            The certificate check reported: ${{ steps.monitor.outputs.status }}
            See which host(s) triggered it here:
            ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

Two things changed. The standalone “Setup Python” step is gone — it now lives inside the composite action, invisible from here. And the email step’s body: now includes ${{ steps.monitor.outputs.status }}, reading the composite action’s output the same way you’d read any other step’s output — the id: monitor on the calling step is what makes steps.monitor resolve, exactly like id: run-check did inside the action itself.

Notice the path, too: uses: ./.github/actions/ssl-cert-monitor points to a directory containing an action.yml, not a specific file. Compare that with uses: ./.github/workflows/check-certs.yml in your caller workflows from chapter 39 — reusable workflows are referenced by file, composite and other custom actions are referenced by folder. Easy to mix up, worth keeping straight.

Hands On: Try It

Add changes and commit:

git add .github/workflows/check-certs.yml \
        .github/actions/ssl-cert-monitor/action.yml
git commit -m "Use Composite Action"
git push

Trigger check-one-domain.yml again the same way you did last chapter — a domain and a warning window high enough that the check fails on purpose:

  1. Actions tab → Check a Single Domain → Run workflow.
  2. Domain: example.com. Warning window: 9999.
  3. Run it. In the job’s step list, “Check certificates” now shows as one collapsed entry rather than two separate steps — expand it, and you’ll see Setup Python and Run certificate check running underneath, exactly as before, just packaged.
  4. Confirm the email still arrives, and that its body includes the status text (issue, in this case) instead of a blank line.
The certificate check reported: issue
See which host(s) triggered it here:
https://github.com/xyizlop/devstuffs-practice-actions-reusability/actions/runs/34970203514

The behavior hasn’t changed at all — what changed is that “check a certificate and report a status” is now a single, reusable unit with its own name, instead of two steps that happened to always appear together. Next up: what your other options would have looked like if a composite action wasn’t the right tool for this job.

Last updated on