When you create a small CLI tool, it is common to consider publishing it on npm for others to use.
However, there is a distribution path that requires no account or publishing process: GitHub Actions.
- uses: your-name/tool-name@v1
With this single line, the tool becomes usable. I have done this myself, so here are the steps and common pitfalls.
Composite vs Docker
There are three types of Actions (JavaScript / Docker / composite), but
if the tool runs on Node, composite is the only choice.
| composite | Docker | |
|---|---|---|
| Startup | Fast | Requires building or pulling an image |
| Execution Environment | Uses the runner's environment | Isolated |
| Implementation | Just list shell steps | Requires a Dockerfile |
Docker isolation is necessary for tools that modify the system or require bundling special dependencies. Using Docker for a tool that completes with just Node yields no return on the startup cost.
Full Code
Place action.yml in the root of your repository.
name: 'mdlinkcheck'
description: 'Find broken relative links in Markdown files.'
author: 'your-name'
branding:
icon: 'link-2'
color: 'orange'
inputs:
path:
description: 'Directory or file to check.'
required: false
default: '.'
format:
description: 'Output format: text or json.'
required: false
default: 'text'
runs:
using: 'composite'
steps:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build
shell: bash
working-directory: ${{ github.action_path }}
run: |
npm ci --no-audit --no-fund
npm run build
- name: Check links
shell: bash
run: |
node "${{ github.action_path }}/bin/mdlinkcheck.js" \
"${{ inputs.path }}" --format "${{ inputs.format }}"
That is all.
Pitfalls
shell: is required for every step
Composite run steps will error if you do not specify shell: for each one.
It can be omitted in normal workflows, so it is easy to forget.
Use ${{ github.action_path }}
This is the absolute path of the directory where the action is located. Since it runs in the user's repository,
the current directory belongs to the user.
# NG: Tries to run npm ci in the user's repository
run: npm ci
# OK
working-directory: ${{ github.action_path }}
run: npm ci
Specify the tool execution with an absolute path as well.
run: node "${{ github.action_path }}/bin/tool.js" "${{ inputs.path }}"
Exit codes are passed through directly
If a shell step ends with a non-zero exit code, the step fails.
Unless you add || true or write continue-on-error,
the tool's exit code becomes the CI pass/fail result.
This is effective, so it is worth designing your tool's exit codes properly.
0 No issues
1 Issues found (CI should fail)
2 Usage error (missing path, invalid option)
Whether to commit dist/
With JavaScript actions, you must commit dist/, but
with composite, you can just run npm ci && npm run build on the action side, so it is unnecessary.
This means a build runs every time. It takes a few seconds, which I accept.
If this concerns you, you can use actions/cache.
Tagging Versions
Guiding users to @main means users will inadvertently pull breaking changes.
git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0
# v1 is the "working tag". It always points to the latest 1.x
git tag -f v1
git push -f origin v1
Guide users to use @v1, and let them know that @v1.0.0 is available for pinning.
Advance v1 with each release.
git tag -f v1 && git push -f origin v1
Verify Published Tags Work in CI
This was the most effective step.
It is easy to verify functionality by using uses: ./ in your own repository,
but even if that passes, if the commit pointed to by the tag is broken, users will not be able to use it.
This is because ./ only looks at the local checkout.
Therefore, add a job to CI that "executes by referencing the published tags."
jobs:
use-the-action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./ # Local
with:
path: .
published-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: your-name/your-tool@v1 # Published tag
with:
path: .
With this, your CI will fail the moment you make a mistake in retagging.
You can catch it before it fails in users' repositories.
For tools that can run against themselves (linters, formatters,
link checkers, etc.), this format is particularly suitable.
"Usable" becomes more than just a claim; it is verified every time.
Whether to Publish to the Marketplace
If you write branding in action.yml, GitHub's UI will prompt you to publish to the Marketplace. You do not need to rush, as it can be used via uses: even without publishing.
Publishing adds one more discovery path, so it is worth doing after refining your repository description and topics.
Summary
- For tools running on Node, use composite. If Docker isolation is not needed, there is no reason to pay the cost.
-
shell:is required for every step. Do not forget it. - Paths must use
${{ github.action_path }}. The current directory is the user's repository. - Exit codes directly determine CI pass/fail. Design them in your tool.
- Do not guide users to
@main. Create a working tagv1and a pinned tagv1.0.0. -
Place a job in CI that references the published tag. Even if
uses: ./passes, users will not be able to use it if the tag is broken.
I have open-sourced a small CLI (Markdown link checker) distributed in this format under the MIT license.
I publish the configuration for splitting Claude Code into separate personas —
Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run
./setup.sh, and it works. It does not depend on your tech stack.
https://github.com/quintetkit/quartet
I built one real tool using nothing but this workflow. Every Issue, PR, review
and merge is still there. The parts that went wrong were not deleted.
https://github.com/quintetkit/mdlinkcheck
The version that adds a UI Designer persona, review criteria, a per-Issue
parallel execution script and a 10-chapter guide is on the
product page.
Top comments (0)