Skip to content

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.py

Copy 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_code

The 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.0 gets 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-compatible v1.x.x. Someone pinned to @v1 automatically 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 v1

Later, 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 --force

Note

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:

  1. 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.
  2. 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.
  3. GitHub validates action.yml at this point. Fix anything it flags — commonly the branding.icon or branding.color values — until it shows “Everything looks good!”
  4. Pick a Primary Category (something like “Monitoring” fits this action) and, optionally, a secondary one.
  5. In the tag field, use v1.2.0 — the exact release tag from above, not the moving v1 tag.
  6. 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.

Last updated on