10. Writing your own action
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Three kinds of actions
Section titled “Three kinds of actions”Lesson 6 wrote a composite action: YAML steps. The two other kinds run code:
| Composite | JavaScript | Docker container | |
|---|---|---|---|
runs.using |
composite |
node24 (or node20) |
docker |
| Runs | steps in the caller’s job | node on the runner |
a container built or pulled on the runner |
| Operating systems | all | all | “can only execute on runners with a Linux operating system” |
| Inputs | ${{ inputs.x }} |
INPUT_X environment variables |
INPUT_X variables and args |
| Startup cost | none | none | building or pulling the image |
| C# analogy | a method made of other method calls | a small console app | a console app shipped with its own OS |
| Azure Pipelines | step template | custom task (Node) | container job |
Both examples below live in this repository and are tested by .github/workflows/gha-10-action.yml.
A JavaScript action without dependencies
Section titled “A JavaScript action without dependencies”The same slug function as the .NET and Java code of lesson 2, as an action. .github/actions/slugify/action.yml:
# GitHub Actions course, lesson 10: a JavaScript action without dependenciesname: Slugifydescription: Turns a title into a URL slug, like the sample code of lesson 2.
inputs: text: description: The text to turn into a slug required: true max-length: description: Maximum length of the slug, 0 for no limit default: '0'
outputs: slug: description: The slug
runs: using: node24 main: index.js// GitHub Actions course, lesson 10: inputs arrive as INPUT_<NAME> variables, outputs go to the GITHUB_OUTPUT fileimport fs from "node:fs";
function input(name, { required = false } = {}) { const variable = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; const value = (process.env[variable] ?? "").trim(); if (required && value === "") { throw new Error(`Input required and not supplied: ${name}`); } return value;}
function slugify(text) { return text .normalize("NFD") .replace(/\p{Mn}/gu, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "");}
try { const text = input("text", { required: true }); const maxLength = Number(input("max-length") || "0"); let slug = slugify(text); if (maxLength > 0) { slug = slug.slice(0, maxLength).replace(/-+$/, ""); } console.log(`slug of "${text}" is "${slug}"`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `slug=${slug}\n`);} catch (error) { console.log(`::error::${error.message}`); process.exitCode = 1;}- The runner passes each input as an environment variable: “converts input names to uppercase letters and replaces spaces with
_characters” (Runner.Worker/Handlers/Handler.cs). Hyphens stay:max-lengthbecomesINPUT_MAX-LENGTH, a name bash can’t read as$INPUT_MAX-LENGTHbut Node can. - Outputs use the same
$GITHUB_OUTPUTfile asrun:steps (lesson 4); errors use the same::error::workflow command. - Most real actions use the
@actions/corepackage for this (core.getInput,core.setOutput,core.setFailed), and then must commitnode_modulesor a bundleddist/index.js: the runner doesn’t runnpm install. Without dependencies, there’s nothing to bundle.
Because the action is just a script, test it locally before any push, with the variables the runner would set:
$env:INPUT_TEXT = "C# 14 & .NET 10"; $env:GITHUB_OUTPUT = "out.txt"; node .github/actions/slugify/index.jsINPUT_TEXT="C# 14 & .NET 10" GITHUB_OUTPUT=out.txt node .github/actions/slugify/index.jsINPUT_TEXT="C# 14 & .NET 10" GITHUB_OUTPUT=out.txt node .github/actions/slugify/index.jsslug of "C# 14 & .NET 10" is "c-14-net-10"The same three cases as the .NET and Java tests gave hello-world, github-actions and c-14-net-10; an empty text gave ::error::Input required and not supplied: text and exit code 1.
Using it
Section titled “Using it”From .github/workflows/gha-10-action.yml, lines 23-29:
- id: slug uses: ./.github/actions/slugify with: text: GitHub Actions course, lesson 10 — Wörld! max-length: 30 - shell: bash run: echo "slug output = ${{ steps.slug.outputs.slug }}"On the three operating systems:
javascript (ubuntu-latest) | slug of "GitHub Actions course, lesson 10 — Wörld!" is "github-actions-course-lesson-1"javascript (windows-latest) | slug of "GitHub Actions course, lesson 10 — Wörld!" is "github-actions-course-lesson-1"javascript (macos-latest) | slug of "GitHub Actions course, lesson 10 — Wörld!" is "github-actions-course-lesson-1"javascript (ubuntu-latest) | slug output = github-actions-course-lesson-1Same result everywhere, and the action step took under a second. Note the 1: max-length: 30 cut 10 in half. The code does exactly what it says; whether a slug should stop at a word boundary is a specification question the test didn’t ask.
A container action
Section titled “A container action”.github/actions/hello-container has three files:
.github/actions/hello-container/action.yml:
# GitHub Actions course, lesson 10: a container action (Linux runners only)name: Hello from a containerdescription: Prints the operating system of the container it runs in.
inputs: who: description: Who to greet default: world
runs: using: docker image: Dockerfile args: - ${{ inputs.who }}.github/actions/hello-container/Dockerfile:
# GitHub Actions course, lesson 10: the image of the container action, built on the runner at every runFROM alpine:3.22COPY entrypoint.sh /entrypoint.shENTRYPOINT ["/entrypoint.sh"].github/actions/hello-container/entrypoint.sh:
#!/bin/sh# GitHub Actions course, lesson 10: runs inside the container; the workspace is mounted at /github/workspaceset -eecho "hello $1 from $(. /etc/os-release && echo "$PRETTY_NAME")"echo "workdir: $(pwd)"echo "INPUT_WHO=$INPUT_WHO"echo "greeting=hello $1" >> "$GITHUB_OUTPUT"First run: permission denied
Section titled “First run: permission denied”##[command]/usr/bin/docker build -t 128b89:7c5dc9951c794fae826f3098f2786173 -f "/home/runner/work/learn/learn/./.github/actions/hello-container/Dockerfile" "/home/runner/work/learn/learn/.github/actions/hello-container"...docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "/entrypoint.sh": permission deniedThe file was written on Windows, where Git doesn’t track the executable bit (git config core.filemode → false), so it was committed as 100644 — left that way on purpose, to capture this error, which every Windows author of a container action meets once. The fix is in Git, not in the Dockerfile:
git update-index --chmod=+x .github/actions/hello-container/entrypoint.shgit diff --cached --summary mode change 100644 => 100755 .github/actions/hello-container/entrypoint.shSecond run
Section titled “Second run”hello Grace from Alpine Linux v3.22workdir: /github/workspaceINPUT_WHO=Gracegreeting output = hello GraceThe runner’s docker run command shows how the container sees the job:
-v "/var/run/docker.sock":"/var/run/docker.sock"-v "/home/runner/work/_temp":"/github/runner_temp"-v "/home/runner/work/_temp/_github_home":"/github/home"-v "/home/runner/work/_temp/_github_workflow":"/github/workflow"-v "/home/runner/work/_temp/_runner_file_commands":"/github/file_commands"-v "/home/runner/work/learn/learn":"/github/workspace"The workspace is mounted, so the container can read the checked-out code, and $GITHUB_OUTPUT points into the mounted file_commands folder, so outputs work. The step took 5 seconds, most of it building the image — against under a second for the JavaScript action. A published container action usually points image: to a prebuilt image (docker://ghcr.io/…) to skip the build.
Publishing, briefly
Section titled “Publishing, briefly”An action in its own public repository, with action.yml at the root, is used as owner/repo@v1. The conventions, from Manage custom actions: release with semantic version tags (v1.2.0) and move a major tag (v1) to the latest compatible release — the moving tag that lesson 7 advises consumers not to trust blindly. Listing it on the Marketplace is optional. Not done in this course: the actions here stay local.
Key takeaways
Section titled “Key takeaways”- JavaScript actions run on all runners and start instantly; container actions bring their own Linux and run only on Linux runners.
- Inputs arrive as
INPUT_<NAME>variables (uppercase, hyphens kept); outputs and errors use$GITHUB_OUTPUTand workflow commands, like any step. - The runner doesn’t enforce
required: true: the action must check. - Commit what the action needs to run: no
npm installon the runner, executable bits for scripts, and mind the repository’spackage.json.
Exercises
Section titled “Exercises”- A workflow passes
txt: Helloinstead oftext: Helloto the slugify action. What does the runner do with the unknown input, and with the missing required one?
Solution
From gha-10-exercises.yml:
##[warning]Unexpected input(s) 'txt', valid inputs are ['text', 'max-length']##[error]Input required and not supplied: textThe unknown input only produces a warning, and the step ran anyway. The error comes from the action’s own code: “Actions using required: true will not automatically return an error if the input is not specified.” Without the check in index.js, the action would have produced an empty slug and a green step.
- What happens when
hello-containeris used in awindows-latestjob?
Solution
##[error]Container action is only supported on LinuxThe step fails immediately, before any build. An action meant for all three operating systems must be JavaScript or composite.
- The first version of
index.jsstarted withconst fs = require("node:fs");, valid Node.js. Would it run on the runner?
Solution
ReferenceError: require is not defined in ES module scope, you can use import insteadThis file is being treated as an ES module because it has a '.js' file extension and '/home/runner/work/learn/learn/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.Node looks for the nearest package.json above the script, and in this repository that’s the Astro site’s, which declares "type": "module". No. The error above comes from the runner, running the exercise-commonjs copy; the same error had appeared locally first. Fixes: import syntax (chosen here), an index.cjs file, or a package.json in the action’s folder. In a dedicated action repository, you control that file.