Skip to content
learn

10. Writing your own action

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.

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 dependencies
name: Slugify
description: 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

index.js:

// GitHub Actions course, lesson 10: inputs arrive as INPUT_<NAME> variables, outputs go to the GITHUB_OUTPUT file
import 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-length becomes INPUT_MAX-LENGTH, a name bash can’t read as $INPUT_MAX-LENGTH but Node can.
  • Outputs use the same $GITHUB_OUTPUT file as run: steps (lesson 4); errors use the same ::error:: workflow command.
  • Most real actions use the @actions/core package for this (core.getInput, core.setOutput, core.setFailed), and then must commit node_modules or a bundled dist/index.js: the runner doesn’t run npm 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:

Terminal window
$env:INPUT_TEXT = "C# 14 & .NET 10"; $env:GITHUB_OUTPUT = "out.txt"; node .github/actions/slugify/index.js
slug 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.

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-1

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

.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 container
description: 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 run
FROM alpine:3.22
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/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/workspace
set -e
echo "hello $1 from $(. /etc/os-release && echo "$PRETTY_NAME")"
echo "workdir: $(pwd)"
echo "INPUT_WHO=$INPUT_WHO"
echo "greeting=hello $1" >> "$GITHUB_OUTPUT"
##[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 denied

The 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:

Terminal window
git update-index --chmod=+x .github/actions/hello-container/entrypoint.sh
git diff --cached --summary
mode change 100644 => 100755 .github/actions/hello-container/entrypoint.sh
hello Grace from Alpine Linux v3.22
workdir: /github/workspace
INPUT_WHO=Grace
greeting output = hello Grace

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

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.

  • 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_OUTPUT and 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 install on the runner, executable bits for scripts, and mind the repository’s package.json.
  1. A workflow passes txt: Hello instead of text: Hello to 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: text

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

  1. What happens when hello-container is used in a windows-latest job?
Solution
##[error]Container action is only supported on Linux

The step fails immediately, before any build. An action meant for all three operating systems must be JavaScript or composite.

  1. The first version of index.js started with const 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 instead
This 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.