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.ymlname: '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_codeA few things here that don’t come up in a normal job’s steps: list:
- Every
runstep needs an explicitshell:. In a regular job, the runner picks a sensible default shell for you. Inside a composite action, there’s no such default — leaveshell:off and the action simply fails to run.bashis the right choice on theubuntu-latestrunner you’ve been using all course. - The exit code has to be captured, not swallowed.
ssl_cert_checker.pyexiting1or2is 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 friendlierstatusoutput before the step ends.set +e/set -ebrackets 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
statusoutput only exists becauserun-checkset it.steps.run-check.outputs.statusinside the top-leveloutputs:block is how a composite action exposes something one of its internal steps produced, using the sameid: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:
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 pushTrigger 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:
- Actions tab → Check a Single Domain → Run workflow.
- Domain:
example.com. Warning window:9999. - 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 PythonandRun certificate checkrunning underneath, exactly as before, just packaged. - 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/34970203514The 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.