Skip to content

Setup Project Tour


Like the previous section, we are starting this section with a simple, new project in hand. The new one is practice-github-actions-reusability — which has a small, real, zero-dependency command line tool that checks how many days are left before a website’s TLS certificate expires. Anyway we will be focusing more on reusability rahter than on the tool itself as a DevOps practitioner. Fork (maybe star) it now, and clone your fork locally. Everything in this chapter and the rest of this Section happens inside that fork.

What You’re Working With

You will see the whole ready final structure:

.
├── .github
│   ├── actions
│   │   └── ssl-cert-monitor
│   │       └── action.yml
│   └── workflows
│       ├── check-certs.yml
│       ├── check-one-domain.yml
│       ├── lint-and-test.yml
│       └── scheduled-cert-check.yml
├── .gitignore
├── README.md
├── ssl_cert_checker.py
└── tests
    ├── __init__.py
    └── test_ssl_cert_checker.py

But in order to practice, you must delete the whole .github directory:

rm -r .github

Now you are ready to start:

.
├── .gitignore
├── README.md
├── ssl_cert_checker.py
└── tests
    ├── __init__.py
    └── test_ssl_cert_checker.py

One script, no requirements.txt, no virtual environment to set up (but we do have test suites to make sure we are following standards). That’s the point — ssl_cert_checker.py only imports things that ship with Python itself (argparse, socket, ssl, json, sys, datetime). You can run it right now with nothing but a Python 3 interpreter:

python3 ssl_cert_checker.py example.com

That connects to example.com on port 443, reads the certificate the server presents, and prints something like:

[OK] example.com:443
  subject : CN=example.com
  issuer  : CN=R3, O=Let's Encrypt, C=US
  expires: 2026-08-31T00:00:00Z
  days   : 36
  detail : Certificate valid for 36 more day(s).

Why does this matter for a CI/CD course? Because it gives you a script with an honest reason to run on a schedule, an honest reason to fail loudly, and an honest reason to notify a human. That’s exactly the shape of project the rest of tihs section is built around.

Note

The script deliberately turns off hostname/chain verification when it reads the certificate. That’s not a security bug — it’s what lets the tool report the real expiry date even for self-signed or misconfigured certificates. It’s a reporting tool, not a hardening scanner.

CLI Usage

You’ll be wiring this script into workflows for the rest of the section, so get comfortable with it locally first.

Check a single host:

python3 ssl_cert_checker.py example.com

Check several hosts in one run — this is the form you’ll actually use in CI, since a real workflow checks more than one domain:

python3 ssl_cert_checker.py example.com github.com www.google.com

Change the warning window with --days (default is 30 days). This asks it to flag anything expiring within two weeks:

python3 ssl_cert_checker.py --days 14 example.com

Feed it a list of hosts from stdin instead of the command line — useful once you’re pulling a domain list from a file in a later chapter:

cat my_hosts.txt | python3 ssl_cert_checker.py --stdin

And get machine-readable output with --json, which is what you’d reach for if you ever piped the result into another tool:

python3 ssl_cert_checker.py --json example.com
[
  {
    "host": "example.com",
    "port": 443,
    "status": "ok",
    "subject": "commonName=example.com",
    "issuer": "countryName=US, organizationName=SSL Corporation, commonName=Cloudflare TLS Issuing ECC CA 3",
    "not_before": "2026-07-29T22:10:08Z",
    "not_after": "2026-10-27T22:17:21Z",
    "days_remaining": 42,
    "message": "Certificate valid for 42 more day(s)."
  }
]

The Exit Code Contract

This is the part of the tool that matters most for this section, so don’t skim it. Everything from the next chapter onward — the reusable workflow, the email alert, the composite action — hinges on the reader (and GitHub Actions) being able to trust this contract:

Exit codeMeaning
0Every checked certificate is outside the warning window. Nothing to do.
1At least one certificate is already expired, or inside the warning window.
2At least one host couldn’t be checked at all — DNS failure, connection refused, TLS handshake error.

Try it yourself. This succeeds silently:

python3 ssl_cert_checker.py example.com
echo "Exit code: $?"

This fails with 1, because 36 days is inside a 60-day warning window:

python3 ssl_cert_checker.py --days 60 example.com
echo "Exit code: $?"

And this fails with 2, because the host doesn’t exist:

python3 ssl_cert_checker.py this-domain-does-not-exist.invalid
echo "Exit code: $?"

The reason this matters so much for CI: a shell command’s exit code is the only thing a workflow step natively understands as pass/fail. A script that always exits 0 regardless of what happened is useless to automate around, no matter how nice its printed output looks. This one gives you three distinct, meaningful outcomes to build logic on top of — you’ll use exactly that in chapter 39, when a workflow_call step turns a 1 or 2 into a job failure that later steps can react to.

About a Test Suite

Tip

It’s not important to understand the test suite. We are focusing on CI/CD pipeline, not writing or understanding the perfect test suite. Read through it, ignore if you don’t understand. I (would) have done the same.

The repository ships with simple test suites — not because testing is this section’s focus, but because a project with no tests can’t honestly claim to have working CI, and the previous section’s project building-real-ci-pipelines already gave you everything you need to write these.

The test layout:

tests/
├── __init__.py
└── test_ssl_cert_checker.py

tests/__init__.py is empty — it just marks the folder as a package.

The interesting part is test_ssl_cert_checker.py. The script’s core logic — turning a certificate’s dates into a status and a day count — lives inside check_host(), which normally opens a real TLS connection.

But you don’t want your test suite depending on the internet or on some real website’s certificate happening to be in whatever state you need for a given test. So instead, you mock the handshake: you replace the pieces check_host() calls (DNS lookup, socket connection, TLS wrap) with fakes that hand back a certificate dict you control, with whatever dates you want.

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import ssl_cert_checker as scc


CERT_TIME_FMT = "%b %d %H:%M:%S %Y GMT"


def _cert_time(dt: datetime) -> str: ...


def _fake_cert(
    not_before: datetime, not_after: datetime, common_name: str = "example.com"
) -> dict: ...


def _fake_ssl_context(cert: dict) -> MagicMock: ...


def _fake_raw_socket() -> MagicMock: ...


@patch("ssl_cert_checker._default_context")
@patch("ssl_cert_checker.socket.create_connection")
@patch("ssl_cert_checker.socket.getaddrinfo")
def test_check_host_reports_ok_for_a_healthy_certificate(
    mock_getaddrinfo, mock_create_connection, mock_default_context
): ...


@patch("ssl_cert_checker._default_context")
@patch("ssl_cert_checker.socket.create_connection")
@patch("ssl_cert_checker.socket.getaddrinfo")
def test_check_host_reports_expired_for_a_lapsed_certificate(
    mock_getaddrinfo, mock_create_connection, mock_default_context
): ...


@patch("ssl_cert_checker.socket.getaddrinfo")
def test_check_host_reports_error_when_dns_fails(mock_getaddrinfo): ...


def _canned_result(status: str, days_remaining: int) -> scc.CertResult: ...


@patch("ssl_cert_checker.check_host")
def test_main_exits_zero_when_everything_is_healthy(mock_check_host, capsys): ...


@patch("ssl_cert_checker.check_host")
def test_main_exits_one_when_inside_the_warning_window(mock_check_host, capsys): ...


@patch("ssl_cert_checker.check_host")
def test_main_exits_one_when_expired(mock_check_host, capsys): ...


@patch("ssl_cert_checker.check_host")
def test_main_exits_two_when_unreachable(mock_check_host, capsys): ...

Two things worth noticing about this suite (see full code at your local clone), since both patterns will come up again later in this course:

  • The first three tests mock the TLS handshake itself, to prove the days-remaining math and status logic are correct.
  • The last four mock check_host() entirely and call main() directly, to prove the exit-code contract from the table above actually holds. This is the part that matters most — a workflow step’s pass/fail behavior depends on it.

Install pytest and run the suite from the repo root:

pip3 install pytest
python3 -m pytest
============================= test session starts ==============================
platform linux -- Python 3.13.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/devstuffs-practice-actions-reusability
collected 7 items

tests/test_ssl_cert_checker.py .......                                   [100%]

============================== 7 passed in 0.06s ===============================

A Baseline Workflow

One last piece of setup before actual content starts: a workflow that lints and tests the tool’s own code on every push. This is just hygiene — it doesn’t monitor any certificates, and it has nothing to do with reusable workflows yet. You’ve built this exact shape of workflow before, so this is brief on purpose.

Create .github/workflows/lint-and-test.yml and write the workflow below:

mkdir -p .github/workflows/
touch .github/workflows/lint-and-test.yml
.github/workflows/lint-and-test.yml
name: Lint and Test

on:
  push:

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

      - name: Setup Python
        uses: actions/setup-python@v7
        with:
          python-version: "3.13"

      - name: Install dependencies
        run: python -m pip install --upgrade pip pytest

      - name: Lint (ruff)
        uses: astral-sh/[email protected]

      - name: Run tests
        run: python -m pytest
git add .github/workflows/lint-and-test.yml
git commit -m 'Add Lint and Test Workflow'
git push

Check the Actions tab — you should see it check out the repo, install pytest, lint ssl_cert_checker.py and your new test file with ruff, then run the suite. No matrix, no caching, no artifacts — none of that is the point here. This job exists purely so that every later chapter in this section is building on top of code that’s known to be linted and tested, not so it can teach you anything new about workflow structure.

Tip

If ruff flags something in ssl_cert_checker.py itself or the inside test suite at tests/tset_ssl_cert_checker.py, that’s fine — simply add # ruff: noqa before any python code starts to ignore ruff checks — the goal is to learn reuable workflow, not fixing ruff errors here even though fixing is part of it.

With the project forked, its exit code contract understood, a real test suite in place, and a green baseline workflow running on every push, you’re ready for the actual subject of this section: turning the workflow logic itself into something reusable.

Last updated on