SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Author and maintain actions

Create Custom GitHub Actions: JavaScript, Docker and Composite Actions (GH-200)

21 min readGH-200 · Author and maintain actionsUpdated

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.

What you’ll learn
  • 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.

JavaScriptDocker containerComposite
runs.usingnode24 (or node20)dockercomposite
Runner operating systemsLinux, Windows, macOSLinux onlyAny the job uses (its steps must suit it)
Start-up costLowest: Node.js is already on the runnerImage build (Dockerfile) or pull (prebuilt image) every jobWhatever its steps do
EnvironmentThe runner's, plus your bundled packagesSelf-contained: the image fixes the OS packages and tool versionsThe runner's; anything extra is installed by its steps
Can call other actionsNo (it is code, not steps)NoYes, with uses steps
Typical useAPI calls through the Actions toolkit, logic that must be fast and cross-platformTools with native libraries or a fixed language version that must not change with the runner imagePackaging 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: true is 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 example core.getInput('token', { required: true }), which throws an error naming the input when it is empty.
  • default supplies a value when the workflow omits the input (it can be an expression such as ${{ github.token }}).
  • Token inputs and github.token. The job's GITHUB_TOKEN is not placed in a step's environment unless the workflow maps it (with: or env:). An action can still reach it through the github.token context, even when the workflow passes nothing, which is why actions commonly declare a token input with default: ${{ 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.
  • deprecationMessage logs 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), which core.getInput reads for you. Composite actions do not get INPUT_ variables; they read ${{ inputs.<id> }}.

Directory structure and how workflows reference it

Where the action livesuses: formNotes
Root of a public repositoryowner/repo@refThe ref (tag, branch or commit SHA) always comes last.
Subfolder of a repository (several actions in one repo)owner/repo/path@refFor example acme/devops-actions/scan@v1; each folder has its own action.yml.
Same repository as the workflow./path/to/actionRead from the workspace, so actions/checkout must run first; no @ref.
Prebuilt container imagedocker://image:tagRuns 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_modules alongside the code and point main at the entry file.
  • Bundle the code and its modules into one file with a compiler such as @vercel/ncc, commit the output (often dist/index.js) and point main at 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:

  • shell is required on every run step. An action has no defaults.run.shell, and the calling job's defaults do not reach inside the action, so a missing shell fails with a required-property error before the step runs.
  • Outputs need an explicit value. In a JavaScript or Docker action, writing to GITHUB_OUTPUT sets the action's output directly. In a composite action, each outputs.<id>.value must 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 reads steps.<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.sh looks 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_PATH environment variable, which point at the downloaded action's directory and are supported only in composite actions. working-directory changes where a command runs, but does not tell it where the action was downloaded.
  • Inputs come from the inputs context, not INPUT_ variables. A script ported from a JavaScript or Docker action that echoes $INPUT_TARGET prints 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 keyWhat it does
imageWhich image: Dockerfile path (built) or docker:// reference (pulled). Required.
entrypointOverrides the Dockerfile's ENTRYPOINT; it does not change the image.
argsA list of strings passed to the container; overrides the Dockerfile's CMD. With no args, CMD is used.
envEnvironment variables set in the container.
pre-entrypoint / post-entrypointCommands 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 to GITHUB_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 denied means the file lacks the executable bit; set it in Git (chmod +x then commit, or git 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'.

  • pre is not supported for local actions. A ./path action's files only appear when checkout runs, which is after the job-start moment when pre scripts run. Its main and post still run.
  • Each script is its own process. main and post do not share memory, so a module-level variable set in main is gone in post; a process exit handler 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 the GITHUB_STATE file; the runner exposes the value only to that action's later scripts as a STATE_<name> variable, which core.getState(name) reads in post (for example a key such as tmpPath holding a scratch folder to delete). core.exportVariable would 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 callMechanismEffect
core.setOutput(name, value)Appends to GITHUB_OUTPUTSets a step output the caller reads as steps.<id>.outputs.<name>
core.exportVariable(name, value)Appends to GITHUB_ENVEnvironment variable for all later steps in the job
core.addPath(dir)Appends to GITHUB_PATHDirectory prepended to PATH for all later steps
core.saveState(name, value)Appends to GITHUB_STATEValue 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 = 1Fails 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.env changes only the current process. A setup action that downloads a CLI must add the tool's folder with core.addPath so later run steps can call the CLI by name; setting process.env.PATH affects only the action itself.
  • The step result comes from the exit code. core.error only prints an annotation, so the step still passes. core.setFailed logs 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:

ReferenceCan it start running different code without a workflow change?
Full-length commit SHANo. 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.

SymptomLikely causeFix
Can't find action.yml, action.yaml or Dockerfile for a ./ actionCheckout has not run yet, or (in a reusable workflow) the caller's repository was checked outCheck out first; reference shared actions as owner/repo/path@ref
Cannot find module '@actions/core'Dependencies not committed; main points at unbundled sourceCommit node_modules or an ncc bundle and point main at it
Container step fails on Windows or macOSDocker actions, docker:// steps and job containers are Linux-onlyRewrite 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 propertyrun step without shellAdd shell to the step in action.yml
Composite output always emptyNo value mappingvalue: ${{ steps.<id>.outputs.<name> }}
Composite step can't find a script shipped with the actionRelative path resolved in the caller's workspaceUse $GITHUB_ACTION_PATH or ${{ github.action_path }}
Post script skipped after a failed steppost-if: success() evaluates the job's statusUse the always() default for cleanup
$INPUT_X empty in a composite actionComposite actions get no INPUT_ variablesMap ${{ inputs.x }} through env
exec /entrypoint.sh: permission deniedEntry script not executableSet the executable bit in Git
Permission denied writing to the workspaceUSER in the DockerfileRemove USER; run as root
File baked into /github/workspace missingWorkspace mounted over that pathStore it elsewhere in the image
Literal $VAR received by the toolExec-form entrypoint does not expand variablesUse ${{ }} expressions in args
Action carries on with an empty input instead of stoppingrequired: true is not enforcedValidate with core.getInput(name, { required: true })
Error annotation shown but step passescore.error does not set the exit codeUse 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.

Key takeaways
  • 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.

Test yourself on this topic
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?