Create Custom GitHub Actions: JavaScript, Docker and Composite Actions (GH-200)
A custom GitHub action is a reusable step defined by an action.yml (or action.yaml) metadata file and implemented as JavaScript running on the runner, a Docker container, or a composite list of workflow steps. This lesson covers how to choose between the three types, which files and metadata keys each needs, how inputs, outputs, pre/post scripts and workflow commands work from inside an action, how immutable actions changed version resolution on hosted runners, and how to diagnose the errors custom actions most often produce.
On this page9 sections
- JavaScript, Docker container or composite: which action type fits
- Required files, metadata and directory structure
- JavaScript actions: the runs block and shipping dependencies
- Composite actions: steps, shell, inputs and outputs
- Docker container actions: image, entrypoint, args and Dockerfile rules
- pre and post scripts and passing state between them
- Workflow commands inside actions: environment files versus :: commands
- Immutable actions on hosted runners and what they mean for pinning
- Troubleshooting action execution: symptom, cause, fix
- Choose between JavaScript, Docker container and composite actions from platform, start-up and environment constraints
- Write valid action.yml metadata, including required keys, input behaviour, token defaults and the runs block for each action type
- Lay out action repositories and reference actions by owner/repo/path@ref or a local ./ path
- Ship JavaScript action dependencies, reach files shipped with a composite action, and use pre/post scripts with saved state and job-status conditions
- Apply Dockerfile rules for container actions and predict how image, entrypoint and args behave
- Use toolkit workflow commands from inside an action and explain immutable actions' effect on version pinning
- Diagnose common custom action failures from their log symptoms
JavaScript, Docker container or composite: which action type fits
A custom action is one of three types, and the type decides where it can run, how fast it starts and whether it brings its own environment. JavaScript actions run your Node.js code directly on the runner, so they work on Linux, Windows and macOS and start fastest. Docker container actions run inside a container built from a Dockerfile or pulled from a registry, so they carry their own tools and versions, but they run only on Linux runners and pay for the image build or pull at the start of every job. Composite actions bundle a list of workflow steps (run steps and uses steps) into one step, and run on whatever runner the calling job uses.
| JavaScript | Docker container | Composite | |
|---|---|---|---|
runs.using | node24 (or node20) | docker | composite |
| Runner operating systems | Linux, Windows, macOS | Linux only | Any the job uses (its steps must suit it) |
| Start-up cost | Lowest: Node.js is already on the runner | Image build (Dockerfile) or pull (prebuilt image) every job | Whatever its steps do |
| Environment | The runner's, plus your bundled packages | Self-contained: the image fixes the OS packages and tool versions | The runner's; anything extra is installed by its steps |
| Can call other actions | No (it is code, not steps) | No | Yes, with uses steps |
| Typical use | API calls through the Actions toolkit, logic that must be fast and cross-platform | Tools with native libraries or a fixed language version that must not change with the runner image | Packaging repeated steps (actions plus short scripts) with no code to compile |
Choose by the hard constraint first. If the step must run on Windows or macOS, a Docker action is out, because GitHub runs container actions only on Linux runners; a JavaScript action (or a composite action whose steps suit every OS) is the cross-platform choice. If the result must not depend on what GitHub preinstalls on runner images, the container wins, because a JavaScript action that shells out to host tools or a composite action that installs packages both inherit the image. If the job is "these four steps, everywhere, no build", a composite action is the fit.
Wrapping scripts you already have. When a team already owns Python or shell scripts and needs them on all three operating systems, a composite action is usually the smallest change: one uses: actions/setup-python step pins the interpreter, and run steps call the scripts shipped in the action's own folder (see github.action_path below). A Docker action would also fix the interpreter version, but it rules out Windows and macOS runners. A JavaScript action that spawns the scripts works everywhere, but it adds a Node.js wrapper to write and maintain and still depends on the interpreter installed on the runner.
A composite action is not the same as a reusable workflow. A composite action is one step inside the caller's job, sharing its runner and workspace; a reusable workflow is called at job level (jobs.<job_id>.uses) and runs as its own job. Logic that has to sit among a job's own steps, sharing its files and environment, must therefore be packaged as an action. Choosing between reusable workflows, templates and composite actions as sharing models is covered in the workflow templates and reusable workflows lesson.
The Linux rule covers every container feature, not only container actions. A docker://image:tag step, a job container (jobs.<job_id>.container) and service containers all need a Linux runner too. When most of a job has to stay on Windows or macOS but one step needs a container, move that step into its own job on a Linux runner and pass files between the jobs as an artifact (actions/upload-artifact in one job, actions/download-artifact in the other, with needs setting the order). Installing Docker on a Windows or macOS runner does not lift the restriction.
Self-hosted runners and container actions: to run Docker container actions, a self-hosted runner must run a Linux operating system and have Docker installed. Both are properties of the host machine: runner labels and registration scope route jobs to a runner but add no capabilities the host lacks.
Required files, metadata and directory structure
Every action needs a metadata file named action.yml or action.yaml (either name works) at the root of the action's directory; a Docker action also needs its Dockerfile (unless it pulls a prebuilt image) and usually an entry script. The metadata file has three required top-level keys: name, description and runs. author, inputs, outputs and branding (the Marketplace icon and colour) are optional for every action type.
name: Deploy preview
description: Deploys the current build to a preview environment
inputs:
github-token:
description: Token used to call the API
required: true
token:
description: Old name for github-token
deprecationMessage: Use github-token instead
outputs:
url:
description: Preview URL
runs:
using: node24
main: dist/index.js
How inputs behave
required: trueis documentation, not enforcement. The runner does not fail the step when a required input is missing; the action receives an empty value. To stop early with a clear message, validate in code, for examplecore.getInput('token', { required: true }), which throws an error naming the input when it is empty.defaultsupplies a value when the workflow omits the input (it can be an expression such as${{ github.token }}).- Token inputs and
github.token. The job'sGITHUB_TOKENis not placed in a step's environment unless the workflow maps it (with:orenv:). An action can still reach it through thegithub.tokencontext, even when the workflow passes nothing, which is why actions commonly declare a token input withdefault: ${{ github.token }}: callers get the job token with no extra lines and can override the input with a personal access token or GitHub App token when they need wider access. deprecationMessagelogs a warning whenever a workflow sets that input; the input still reaches the action. Put it on the old input name during a rename.- How the action reads inputs: JavaScript and Docker actions receive each input as an environment variable
INPUT_<NAME>(upper-cased, spaces turned into underscores), whichcore.getInputreads for you. Composite actions do not getINPUT_variables; they read${{ inputs.<id> }}.
Directory structure and how workflows reference it
| Where the action lives | uses: form | Notes |
|---|---|---|
| Root of a public repository | owner/repo@ref | The ref (tag, branch or commit SHA) always comes last. |
| Subfolder of a repository (several actions in one repo) | owner/repo/path@ref | For example acme/devops-actions/scan@v1; each folder has its own action.yml. |
| Same repository as the workflow | ./path/to/action | Read from the workspace, so actions/checkout must run first; no @ref. |
| Prebuilt container image | docker://image:tag | Runs the image directly as a step. |
A ./ path is resolved against GITHUB_WORKSPACE, which is empty when a job starts. A local action used before checkout fails with an error that the runner can't find action.yml, action.yaml or Dockerfile at that path. The same trap appears in a reusable workflow: there the github context belongs to the caller, so actions/checkout with no repository input checks out the caller's repository, and a ./ path points into the wrong repository. Reference the action as owner/shared-repo/path@ref so the runner fetches it from the repository that holds it.
JavaScript actions: the runs block and shipping dependencies
A JavaScript action sets runs.using to a Node.js runtime and runs.main to the entry file; pre and post are optional extra scripts. Use node24. The metadata syntax still parses node20, but GitHub removed Node 20 from Actions runners in September 2026 and JavaScript actions now run on Node 24, so node20 is no longer a runtime the runner provides and is not a valid choice for a current action. javascript is not a valid value, and entrypoint and image are Docker keys that do not belong in a JavaScript action.
runs:
using: node24
main: dist/index.js
post: dist/cleanup.js
The runner never installs your dependencies. It downloads the action's repository at the referenced ref and runs main with Node.js; it does not run npm install or npm ci, whatever package.json or the lock file says. Every module the code requires must therefore be in the repository at that ref. GitHub's guide gives two layouts:
- Commit
node_modulesalongside the code and pointmainat the entry file. - Bundle the code and its modules into one file with a compiler such as
@vercel/ncc, commit the output (oftendist/index.js) and pointmainat it. This is the usual choice for TypeScript actions, which must be compiled anyway.
The classic symptom of getting this wrong is Error: Cannot find module '@actions/core': the repository ignores node_modules and main points at unbundled source. Because package metadata only matters to a tool that installs packages, and the runner is not one, the only lasting fix is to ship the modules with the action. An install in a pre script is fragile too, because it depends on npm and registry access on every runner and adds network work to every job.
Composite actions: steps, shell, inputs and outputs
A composite action sets runs.using: composite and lists runs.steps, which can mix run steps and uses steps. It runs inside the calling job, on the caller's runner and workspace, and its metadata has three rules that differ from a workflow:
shellis required on everyrunstep. An action has nodefaults.run.shell, and the calling job'sdefaultsdo not reach inside the action, so a missingshellfails with a required-property error before the step runs.- Outputs need an explicit
value. In a JavaScript or Docker action, writing toGITHUB_OUTPUTsets the action's output directly. In a composite action, eachoutputs.<id>.valuemust map an inner step's output, usually${{ steps.<inner-id>.outputs.<name> }}. Without it the caller reads an empty value, and the caller cannot reach inner step ids; it readssteps.<its-own-step-id>.outputs.<id>. - Run steps start in the caller's workspace, not the action's folder. A composite step's working directory is
GITHUB_WORKSPACE(the caller's checked-out repository), so a relative path such as./scripts/lint.shlooks for the file in the caller's repository, and so does${{ github.workspace }}. Files shipped with the action are reached through${{ github.action_path }}or the$GITHUB_ACTION_PATHenvironment variable, which point at the downloaded action's directory and are supported only in composite actions.working-directorychanges where a command runs, but does not tell it where the action was downloaded. - Inputs come from the
inputscontext, notINPUT_variables. A script ported from a JavaScript or Docker action that echoes$INPUT_TARGETprints nothing.
name: Read version
description: Reads VERSION and announces the deploy target
inputs:
target:
description: Deploy target
outputs:
version:
description: Detected version
value: ${{ steps.read.outputs.version }}
runs:
using: composite
steps:
- id: read
shell: bash
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- shell: bash
env:
TARGET: ${{ inputs.target }}
run: echo "Deploying $TARGET"
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- shell: bash
run: python "$GITHUB_ACTION_PATH/scripts/notify.py"
The last two steps install Python with actions/setup-python (which puts python on the PATH on Linux, Windows and macOS runners alike) and then run a script stored in the action's own repository, so the path is built from GITHUB_ACTION_PATH; VERSION in the first step is read from the caller's workspace on purpose. Note the second step. Writing ${{ inputs.target }} straight into run works, but the runner pastes the value into the script text before the shell starts, so input that can come from untrusted sources (pull request titles, branch names) can inject commands. Mapping the value to an environment variable with env: and quoting "$TARGET" keeps it data. Script-injection defences in general are covered in the security domain.
Docker container actions: image, entrypoint, args and Dockerfile rules
A Docker container action sets runs.using: docker and a required runs.image. The image value has two forms: a Dockerfile path relative to action.yml (for example Dockerfile), which the runner builds at the start of every job, or a registry image written as docker://<image>:<tag>, which the runner only pulls. A value without the docker:// prefix is treated as a Dockerfile path, so publishing a prebuilt image and switching to docker://ghcr.io/owner/tool:1.4 removes the per-job build time.
runs key | What it does |
|---|---|
image | Which image: Dockerfile path (built) or docker:// reference (pulled). Required. |
entrypoint | Overrides the Dockerfile's ENTRYPOINT; it does not change the image. |
args | A list of strings passed to the container; overrides the Dockerfile's CMD. With no args, CMD is used. |
env | Environment variables set in the container. |
pre-entrypoint / post-entrypoint | Commands run in the container before and after the main entrypoint (the Docker equivalents of pre/post). |
So if a Dockerfile ends with ENTRYPOINT ["/run.sh"] and CMD ["--format=html"], and the metadata sets args: ["--format=json", "--quiet"], the script's arguments are --format=json --quiet: args replace CMD as a whole, they are not merged with it.
Variable expansion. An exec-form ENTRYPOINT ["/app/report"] starts the process without a shell, so arguments are passed literally: $GITHUB_REF or ${GITHUB_REF} in args arrives as that text even though the variable exists in the container. Two fixes: use an expression such as ${{ github.ref }} in args, which the runner resolves before starting the container (no Dockerfile change), or make the Dockerfile start through a shell (shell form or sh -c), which expands variables.
Dockerfile rules for actions (GitHub's Dockerfile support reference):
- Do not use
USER. Docker actions must run as the default Docker user (root); a non-root user cannot write toGITHUB_WORKSPACE, which shows up as permission-denied errors when the tool writes into the checked-out repository. - Do not use
WORKDIR. GitHub sets the working directory and mounts the workspace over/github/workspace, hiding anything the image placed there. Keep files the tool needs elsewhere in the image (for example under/opt/tool/) and reference them by absolute path. - The entry script must be executable.
exec /entrypoint.sh: permission deniedmeans the file lacks the executable bit; set it in Git (chmod +xthen commit, orgit update-index --chmod=+x entrypoint.sh) and push. The bit is stored with the file in the repository, so the fix is always a change to the file itself.
pre and post scripts and passing state between them
An action can run code before and after its main step: pre runs at the start of the job, before the action's main step, and post runs at the end of the job, after the job's steps have finished (post scripts run in reverse order of their actions). Their conditions, pre-if and post-if, default to always(), so a post script runs even when a later step in the caller's job failed. That makes post the place for cleanup the action owns, such as deleting a credentials file or temporary directory, with no extra step for callers.
Status functions in these conditions read the job's status, not the action's. In pre-if and post-if, success(), failure() and always() evaluate against the whole job so far. post-if: success() therefore skips the post script whenever any step of the job failed, even if the action's own main step succeeded. That suits a post script that should only save something after a clean job (a cache save, for example), and is wrong for cleanup, which should keep the always() default. Non-status conditions work too, such as post-if: runner.os == 'Linux'.
preis not supported for local actions. A./pathaction's files only appear when checkout runs, which is after the job-start moment when pre scripts run. Itsmainandpoststill run.- Each script is its own process.
mainandpostdo not share memory, so a module-level variable set in main is gone in post; a processexithandler in main runs when main's step ends, not at the end of the job. - Pass private values with state. Calling
core.saveState(name, value)in main appends to theGITHUB_STATEfile; the runner exposes the value only to that action's later scripts as aSTATE_<name>variable, whichcore.getState(name)reads in post (for example a key such astmpPathholding a scratch folder to delete).core.exportVariablewould also reach post but leaks the value to every later step in the job, and outputs are visible to the caller.
runs:
using: node24
pre: dist/setup.js
main: dist/index.js
post: dist/cleanup.js
post-if: always()Workflow commands inside actions: environment files versus :: commands
An action talks to the runner in two ways: by appending to environment files (for values that must persist after the step) or by printing :: workflow commands to stdout (for logging behaviour). The @actions/core toolkit wraps both; Docker and composite actions write the same files or print the same commands from their scripts.
| Toolkit call | Mechanism | Effect |
|---|---|---|
core.setOutput(name, value) | Appends to GITHUB_OUTPUT | Sets a step output the caller reads as steps.<id>.outputs.<name> |
core.exportVariable(name, value) | Appends to GITHUB_ENV | Environment variable for all later steps in the job |
core.addPath(dir) | Appends to GITHUB_PATH | Directory prepended to PATH for all later steps |
core.saveState(name, value) | Appends to GITHUB_STATE | Value for this action's later scripts only |
core.setSecret(value) | ::add-mask:: | Masks the value in logs |
core.error / warning / notice | ::error:: etc. | Annotation in the log and run summary; does not change the result |
core.startGroup / endGroup | ::group:: | Collapsible log group |
core.setFailed(message) | ::error:: plus process.exitCode = 1 | Fails the step when the process ends |
The old ::set-output and ::save-state stdout commands were replaced by the files; masking, annotations and groups are still :: commands. Two practical consequences:
- Changing
process.envchanges only the current process. A setup action that downloads a CLI must add the tool's folder withcore.addPathso later run steps can call the CLI by name; settingprocess.env.PATHaffects only the action itself. - The step result comes from the exit code.
core.erroronly prints an annotation, so the step still passes.core.setFailedlogs the error annotation and sets the exit code without stopping the code, so cleanup or a diagnostics upload after the catch block still runs.process.exit(1)or an unhandled rejection also fails the step, but stops the action immediately (a rethrown error also prints a stack trace rather than an annotation).
The general workflow-command syntax (annotations with file and line, stop-commands, masking in run steps) is covered in the workflow authoring lesson; this lesson covers calling them from inside an action.
Immutable actions on hosted runners and what they mean for pinning
Immutable actions are action versions published as packages that cannot change after publishing, and GitHub-hosted runners now resolve uses: references to them where they exist. Per GitHub's December 2024 and February 2025 changelog notices, a hosted runner given uses: owner/action@v4 downloads an immutable version if one exists for that reference and otherwise falls back to traditional resolution from the action's Git repository. Consuming an immutable version needs nothing new in the workflow: the uses: syntax is unchanged, and the runner decides where the code comes from.
Registry sources and self-hosted runners. Immutable actions are downloaded from pkg.actions.githubusercontent.com; a firewall rule allowing *.actions.githubusercontent.com already covers it. ghcr.io was listed for publishing immutable versions, not for consuming them. Self-hosted runners whose allow lists did not permit the download host were automatically opted out and kept resolving actions from the Git repository, so their jobs did not fail; allowing the specific host opts them back in.
Pinning implications. Immutability protects only versions that are actually immutable:
| Reference | Can it start running different code without a workflow change? |
|---|---|
| Full-length commit SHA | No. It names one exact snapshot, immutable version or not. |
| Tag with an immutable version, or an immutable release (tag locked, GA October 2025) | No, the published version cannot change. |
| Tag resolved from Git (no immutable version or release) | Yes. A tag can be deleted and re-created on another commit. |
Branch (for example @main or @releases/v3) | Yes. It moves with every push. |
GitHub has since folded this work into immutable releases, so check the current docs for the exact download hosts. The rollout did not convert existing tags into immutable versions, so for most third-party actions a full commit SHA is still the only reference that guarantees the code. Choosing a versioning and release strategy for actions you publish, including immutable releases, is covered in the distribute and maintain actions lesson.
Troubleshooting action execution: symptom, cause, fix
Most action failures trace back to a handful of causes, and the log message usually names which one. Match the symptom first, then check the metadata or Dockerfile rule behind it.
| Symptom | Likely cause | Fix |
|---|---|---|
Can't find action.yml, action.yaml or Dockerfile for a ./ action | Checkout has not run yet, or (in a reusable workflow) the caller's repository was checked out | Check out first; reference shared actions as owner/repo/path@ref |
Cannot find module '@actions/core' | Dependencies not committed; main points at unbundled source | Commit node_modules or an ncc bundle and point main at it |
| Container step fails on Windows or macOS | Docker actions, docker:// steps and job containers are Linux-only | Rewrite as a JavaScript action if every OS is needed, or move the container step into its own Linux job and pass files as an artifact |
| Composite action fails with a missing required property | run step without shell | Add shell to the step in action.yml |
| Composite output always empty | No value mapping | value: ${{ steps.<id>.outputs.<name> }} |
| Composite step can't find a script shipped with the action | Relative path resolved in the caller's workspace | Use $GITHUB_ACTION_PATH or ${{ github.action_path }} |
| Post script skipped after a failed step | post-if: success() evaluates the job's status | Use the always() default for cleanup |
$INPUT_X empty in a composite action | Composite actions get no INPUT_ variables | Map ${{ inputs.x }} through env |
exec /entrypoint.sh: permission denied | Entry script not executable | Set the executable bit in Git |
| Permission denied writing to the workspace | USER in the Dockerfile | Remove USER; run as root |
File baked into /github/workspace missing | Workspace mounted over that path | Store it elsewhere in the image |
Literal $VAR received by the tool | Exec-form entrypoint does not expand variables | Use ${{ }} expressions in args |
| Action carries on with an empty input instead of stopping | required: true is not enforced | Validate with core.getInput(name, { required: true }) |
| Error annotation shown but step passes | core.error does not set the exit code | Use core.setFailed |
When the cause is not obvious, re-run the job with debug logging enabled (the ACTIONS_STEP_DEBUG secret or variable, or "Enable debug logging" on re-run) to see core.debug output and extra step detail; the ACTIONS_RUNNER_DEBUG secret or variable adds the runner's own diagnostic logs, and the "Set up job" log shows which version of each action was downloaded; reading run logs in general is covered in the troubleshooting workflow runs lesson.
Tip. Expect scenario items about building or fixing a custom action: which action type fits a set of constraints (runner operating systems, start-up time, independence from the runner image, one step inside an existing job), and what to change when an action fails. Many show a short action.yml, Dockerfile or uses: snippet and ask which runs block, reference or metadata change is valid, or which arguments a container receives. Others test toolkit behaviour inside JavaScript actions (outputs, PATH, state, failing a step), pre/post timing, composite inputs, outputs and file paths (github.action_path), token input defaults, post-if job-status conditions, Dockerfile rules, and how immutable actions changed version resolution, self-hosted firewall hosts and pinning guarantees. Constraints such as no caller steps, no Dockerfile change or a value that must stay private decide between near-identical options.
- JavaScript actions run on every runner OS and start fastest; Docker actions, docker:// steps and job containers are Linux-only; composite actions package steps (or existing scripts) and can call other actions.
- action.yml or action.yaml must contain name, description and runs; required: true on an input is not enforced, so validate in code.
- An action can read the job token through github.token even when the workflow passes nothing, so token inputs default to ${{ github.token }} and callers override them only for wider access.
- The runner never runs npm install for an action: commit node_modules or an ncc bundle and point main at it, with using: node24.
- Composite actions need shell on every run step, an explicit value for each output and the inputs context instead of INPUT_ variables; their steps start in the caller's workspace, so shipped files are reached through github.action_path.
- In Docker actions, args override CMD, entrypoint overrides ENTRYPOINT, docker:// pulls a prebuilt image, and exec form does not expand $VARIABLES.
- Action Dockerfiles must not use USER or WORKDIR, the workspace is mounted over /github/workspace, and entrypoint.sh must be executable in Git.
- post runs at job end with post-if defaulting to always(); status functions in pre-if/post-if read the job's status; pre is not supported for local actions; saveState/getState pass private values between scripts.
- setOutput, exportVariable, addPath and saveState write environment files; setFailed sets the exit code without stopping the code.
- Hosted runners use an immutable version where one exists and fall back to Git; only a full commit SHA (or an immutable version) guarantees unchanged code.
Frequently asked questions
What is the difference between JavaScript, Docker and composite actions in GitHub Actions?
A JavaScript action runs Node.js code directly on the runner, so it works on Linux, Windows and macOS and starts fastest. A Docker container action runs in a container built from a Dockerfile or pulled from a registry, so it brings its own tools but runs only on Linux runners. A composite action bundles run and uses steps into one step that runs on the calling job's runner, which suits packaging repeated steps without compiled code.
Which keys are required in a GitHub Actions action.yml file?
An action's metadata file, named action.yml or action.yaml, requires name, description and runs. The runs block sets using (node24 or node20, docker, or composite) plus the type's own keys, such as main for JavaScript, image for Docker or steps for composite actions. author, inputs, outputs and branding are optional.
Why does my JavaScript action fail with Cannot find module '@actions/core'?
The GitHub Actions runner does not run npm install for actions; it runs the main file from the repository at the referenced ref. If node_modules is git-ignored and main points at unbundled source, the toolkit package is missing. Commit node_modules, or bundle the code and dependencies into one file with @vercel/ncc, commit it and point main at it.
Why is the output of my composite action empty?
A composite action's outputs are not set by writing to GITHUB_OUTPUT inside its steps alone. Each output in action.yml needs a value that maps an inner step's output, such as value: ${{ steps.read.outputs.version }}. The calling workflow then reads the output from the step that used the action, not from the inner step id.
Why does a Docker container action receive $GITHUB_REF literally instead of its value?
When the Dockerfile uses an exec-form ENTRYPOINT, the container process starts without a shell, so variable references in args are passed as plain text. Use an expression such as ${{ github.ref }} in runs.args, which the runner resolves before starting the container, or start the entrypoint through a shell, which expands environment variables.
How do I pass a value from a JavaScript action's main script to its post script?
Call core.saveState with a name and value in main and core.getState with the same name in post. Main and post run as separate processes, so in-memory variables are lost; saveState writes to the GITHUB_STATE file and the runner exposes the value only to that action's later scripts, not to other steps.
Do immutable actions mean tag references like @v3 are now safe to use?
Not by themselves. GitHub-hosted runners use an immutable version when one exists for the reference and otherwise resolve the tag from the action's Git repository, where it can be moved to another commit. Existing tags were not converted. A full-length commit SHA, or a version that really is immutable, is what guarantees the code does not change without a workflow edit.
Why does a pre script not run for my local action?
pre scripts run at the very start of the job, before any step, but a local action referenced with a ./ path only exists on disk after actions/checkout runs. GitHub's metadata syntax therefore does not support pre for local actions; the action's main and post scripts still run.
Source
This lesson covers the "Author and maintain actions" domain of the official GH-200 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.