Versioning and Publishing Actions
ssl-cert-monitor works, but right now it only works here — it lives inside the ssl-cert-checker repo, calls a script that lives in the same repo, and nobody outside this project could reasonably use it. This chapter turns it into something you could hand to a stranger: a real, publishable action with its own repository, real version tags, and a genuine listing on GitHub Marketplace — “SSL Cert Expiry Monitor,” sitting right alongside the kind of action you’ve been pulling into your own workflows all course.
The question you should not be asking is “Is it production ready?” — maybe, maybe not. The question and the effort is about practicing and simulating how versioning and publishing actions actually take place.
Why It Needs Its Own Repository
GitHub Marketplace has a hard requirement worth knowing up front: a repository is only eligible to publish an action if its action.yml sits at the repository root, and GitHub recommends the repo contain nothing but the action’s own metadata and code. ssl-cert-monitor currently lives at .github/actions/ssl-cert-monitor/action.yml, nested inside a repository whose root is a completely unrelated Python CLI, its tests, and several workflow files. None of that is publishable as-is.
So: create a new public repository — call it ssl-cert-expiry-monitor-action — and move the action there.
Making the Action Self-Contained
Previous chapters left a loose end: ssl-cert-monitor assumed ssl_cert_checker.py would already be sitting in whatever repository called it, which was true only because the action and the script happened to live side by side. That assumption breaks the moment this action leaves the ssl-cert-checker repo — a stranger’s workflow calling this action from the Marketplace has no reason to also have ssl_cert_checker.py checked out.
Fix that by giving the action its own copy of the script. In the new repository:
.
├── action.yml
└── ssl_cert_checker.pyCopy ssl_cert_checker.py in unchanged, and update action.yml to reference it by the action’s own path rather than assuming it’s in the caller’s workspace:
name: 'SSL Cert Expiry Monitor'
description: 'Checks TLS certificate expiry for one or more hostnames and reports a coarse status.'
branding:
icon: 'shield'
color: 'green'
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 "${{ github.action_path }}/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_codeThe one substantive change is "${{ github.action_path }}/ssl_cert_checker.py" in place of the bare ssl_cert_checker.py. github.action_path always points at the folder this action.yml lives in — for a Marketplace action, that’s a folder GitHub checks out on the caller’s runner automatically, regardless of what the caller’s own repository contains. That’s what makes this version of the action genuinely portable: it now carries its own dependency instead of borrowing one from whichever repo happens to call it.
branding: is new too — it’s what gives the Marketplace listing an icon and color instead of a blank placeholder. icon has to be one of a fixed set of Feather icon names, and color one of a fixed set of named colors; GitHub’s publish flow will flag it if either value isn’t recognized, so treat these as a starting guess you may need to adjust from the dropdown GitHub shows you during publishing.
Push this as the initial commit to the new repository.
Two Kinds of Version Tag
You’ve spent this whole course pinning third-party actions to tags like @v7 or @v17, trusting that a repo tagged that way would keep working as its maintainer shipped patches. Now you’re the maintainer, and it’s worth understanding what actually makes that trust reasonable — there are two genuinely different kinds of tag involved, not one:
- An exact release tag, like
v1.2.0— created once, as a real GitHub Release, and never moved again. Someone pinned to@v1.2.0gets exactly that code, forever. - A moving major tag, like
v1— a plain Git tag, deliberately not attached to a GitHub Release, that you repoint every time you ship a new backwards-compatiblev1.x.x. Someone pinned to@v1automatically rides along.
Tag the first real release as an actual GitHub Release with the tag v1.2.0 (through the same “Draft a new release” flow you’d use for any repository). Then create the moving tag separately, as a plain tag rather than a release:
git tag v1
git push origin v1Later, once you’ve tagged v1.3.0 as a new release and confirmed it doesn’t break anything v1.2.0 did, move v1 to point at it:
git tag -f v1
git push origin v1 --forceNote
If this repository ever has GitHub’s immutable releases setting turned on, a tag that’s attached to a published Release can no longer be force-moved — which is exactly why v1 should never be turned into a Release itself, only v1.2.0, v1.3.0, and so on. Keeping the moving tag separate from any Release is what keeps it movable.
This is precisely the choice every consumer of your action gets to make, and it’s the same choice you’ve been making all course without necessarily naming it: pin to @v1 and get automatic patches and minor updates, or pin to @v1.2.0 and get exact, unchanging behavior forever. Neither is more “correct” — a nightly monitoring workflow that just wants to stay current is a reasonable @v1; a workflow that can’t tolerate any surprise behavior change is a reasonable @v1.2.0.
Publishing to the Marketplace
With v1.2.0 tagged as a release and v1 pointing at the same commit, publish it:
- On the repository’s GitHub page, open
action.yml. Because it’s a valid action metadata file at the repo root, GitHub shows a banner offering to Draft a release. - Click it, then under Release Action, check Publish this Action to the GitHub Marketplace.
- If that checkbox is greyed out, the account owning the repository hasn’t accepted the GitHub Marketplace Developer Agreement yet — there’s a link right there to do that first.
- GitHub validates
action.ymlat this point. Fix anything it flags — commonly thebranding.iconorbranding.colorvalues — until it shows “Everything looks good!” - Pick a Primary Category (something like “Monitoring” fits this action) and, optionally, a secondary one.
- In the tag field, use
v1.2.0— the exact release tag from above, not the movingv1tag. - Fill in a release title and notes, then click Publish release. GitHub requires two-factor authentication on the account to complete this step.
Your action is now live at a Marketplace URL, immediately and without any GitHub review — Marketplace listings go live as soon as the requirements above are met.
Tip
Publishing isn’t permanent. To unlist an action later, open its Releases page, edit the release, uncheck Publish this action to the GitHub Marketplace, and update the release. Do that for every release you’d published — the listing itself only fully disappears once none of its releases are still marked as published.
Closing the Loop
Back in ssl-cert-checker, the reusable workflow could now call this Marketplace action instead of the local one:
- name: Check certificates
id: monitor
uses: your-username/ssl-cert-expiry-monitor-action@v1
with:
domains: ${{ inputs.domains }}
warning-days: ${{ inputs.warning-days }}Same inputs, same output, same behavior — the only thing that changed is where the action’s code comes from: a path inside this repo, versus a versioned tag on a public one. That’s not a coincidence. Every third-party action you’ve referenced this entire course — actions/checkout@v7, astral-sh/ruff-action@v4, dawidd6/action-send-mail@v17 — is exactly this same pattern from the other side: someone else’s composite, JavaScript, or Docker action, tagged and published the same way you just did, that you decided to trust with a version pin. You’ve spent this whole section building the thing you’ve been consuming since the first workflow.
Revoke Access
After you verify Email is sent, you can revoke access (remove App Password) from Google and remove from Github Secrets. This means future runs can’t send email and fail with Auth Error but since this is not production and we already know how it’s work — it’s better to revoke access than keeping it unused.