{"id":73,"date":"2025-12-23T12:00:00","date_gmt":"2025-12-23T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=73"},"modified":"2026-10-30T06:47:43","modified_gmt":"2026-10-30T06:47:43","slug":"building-custom-actions-in-github-actions-composite-javascript-docker","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=73","title":{"rendered":"Building Custom Actions in GitHub Actions: Composite, JavaScript &#038; Docker"},"content":{"rendered":"<p class=\"wp-block-paragraph\">After covering the fundamentals, now I want to share how to create your own reusable actions. This is where GitHub Actions becomes really powerful you can package your logic once and use it everywhere.<\/p>\n<p class=\"wp-block-paragraph\">GitHub Actions supports three types of custom actions. I built all three in my home lab, and I&#8217;ll show you exactly how they work.<\/p>\n<h3 class=\"wp-block-heading\">Why Create Custom Actions?<\/h3>\n<p class=\"wp-block-paragraph\">Before diving into code, here&#8217;s why I started building custom actions:<\/p>\n<ol class=\"wp-block-list\"><li><strong>DRY principle:<\/strong> Stop copying the same steps across workflows<\/li><li><strong>Encapsulation:<\/strong> Hide complexity behind simple interfaces<\/li><li><strong>Reusability:<\/strong> Use the same action in multiple projects<\/li><li><strong>Versioning:<\/strong> Update action logic without changing workflows<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Type 1: Composite Actions<\/h3>\n<p class=\"wp-block-paragraph\">Composite actions combine multiple steps into one reusable action. Think of it as a function that wraps several workflow steps.<\/p>\n<p class=\"wp-block-paragraph\">Here&#8217;s my composite action that sets up Node.js and caches dependencies:<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/composite-cache-deps\/action.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: Cache Node and NPM Dependencies\ndescription: This action allows to cache both Node and NPM dependencies based on the package-lock.json file.\n\ninputs:\n  node_version:\n    description: The Node.js version to use\n    required: true\n    default: '20.x'\n  working-dir:\n    description: The working directory where package-lock.json is located\n    required: false\n    default: '.'\n\nruns:\n  using: composite\n  steps:\n  - name: Setup node js\n    uses: actions\/setup-node@v4\n    with:\n      node-version: ${{ inputs.node_version }}\n  \n  - name: Cache NPM dependencies\n    uses: actions\/cache@v4\n    with:\n      path: ${{inputs.working-dir}}\/node_modules\n      key: node-modules-${{ inputs.target-env }}-${{ hashFiles(format('{0}\/{1}', inputs.working-dir, 'package-lock.json')) }}\n  \n  - name: Install NPM dependencies\n    if: steps.cache.outputs.cache-hit != 'true'\n    run: ${{ inputs.target-env == 'dev' &amp;&amp; 'npm install' || 'npm install --omit=dev' }}\n    shell: bash\n    working-directory: ${{ inputs.working-dir }}<\/code><\/pre>\n<h3 class=\"wp-block-heading\">How It Works<\/h3>\n<p class=\"wp-block-paragraph\">1. Define inputs:<\/p>\n<pre class=\"wp-block-code\"><code>inputs:\n  node_version:\n    description: The Node.js version to use\n    required: true\n    default: '20.x'<\/code><\/pre>\n<p class=\"wp-block-paragraph\">These work like function parameters. Users pass values when they use your action.<\/p>\n<p class=\"wp-block-paragraph\">2. Set the action type:<\/p>\n<pre class=\"wp-block-code\"><code>runs:\n  using: composite<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The composite type tells GitHub this action contains multiple steps.<\/p>\n<p class=\"wp-block-paragraph\">3. Write your steps:<\/p>\n<pre class=\"wp-block-code\"><code>steps:\n  - name: Setup node js\n    uses: actions\/setup-node@v4\n    with:\n      node-version: ${{ inputs.node_version }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Access inputs with ${{ inputs.INPUT_NAME }}.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Important:<\/strong> In composite actions, every run step MUST specify shell:<\/p>\n<pre class=\"wp-block-code\"><code>- name: Install NPM dependencies\n  run: npm install\n  shell: bash  # This is required!<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Using the Composite Action<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s my workflow that uses this action:<\/p>\n<p class=\"wp-block-paragraph\">File: workflows\/17-1-custom-actions-composite.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: 17 - 1 - Custom Actions - Composite\nrun-name: 17 - 1 - Custom Actions - Composite | env - ${{ inputs.target-env }}\n\non:\n  workflow_dispatch:\n    inputs:\n      target-env:\n        description: Which environment to target for dependency installation\n        type: choice\n        options:\n          - dev\n          - prod\n\nenv:\n  working-directory: react-app\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: ${{ env.working-directory }}\n    steps:\n      - uses: actions\/checkout@v4\n      \n      - name: Setup Node and NPM Dependencies\n        id: setup-deps\n        uses: .\/.github\/actions\/composite-cache-deps\n        with:\n          node-version: 20.x\n          working-dir: ${{ env.working-directory }}\n          target-env: ${{ inputs.target-env }}\n      \n      - name: Print setup deps output\n        run: |\n          echo \"Installed dependencies: ${{ steps.setup-deps.outputs.installed-deps }}\"\n      \n      - name: Test\n        run: npm run test\n      \n      - name: Build\n        run: npm run build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Key points:<\/p>\n<p class=\"wp-block-paragraph\">The path uses: .\/.github\/actions\/composite-cache-deps tells GitHub to use a local action from my repo.<\/p>\n<p class=\"wp-block-paragraph\">I can pass inputs just like with marketplace actions:<\/p>\n<pre class=\"wp-block-code\"><code>with:\n  node-version: 20.x\n  working-dir: ${{ env.working-directory }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This action runs 3 steps (setup Node, cache, install) but my workflow only shows 1 step.<\/p>\n<h3 class=\"wp-block-heading\">Type 2: JavaScript Actions<\/h3>\n<p class=\"wp-block-paragraph\">JavaScript actions run Node.js code. They&#8217;re more powerful than composite actions because you can write complex logic, make API calls, and interact with GitHub.<\/p>\n<p class=\"wp-block-paragraph\">I built a JavaScript action that checks for dependency updates and creates a pull request automatically.<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/js-dependency-update\/action.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: Update NPM Dependencies\ndescription: Checks if there are updates to NPM packages, and creates a PR with the updated package*.json files.\n\nruns:\n  using: \"node20\"\n  main: \"index.js\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Much simpler! Just tell GitHub:<\/p>\n<ul class=\"wp-block-list\"><li>using: &#8220;node20&#8221; &#8211; Run with Node.js 20<\/li><li>main: &#8220;index.js&#8221; &#8211; This is the entry point<\/li><\/ul>\n<p class=\"wp-block-paragraph\">File: actions\/js-dependency-update\/index.js<\/p>\n<pre class=\"wp-block-code\"><code>const core = require('@actions\/core');\nconst exec = require('@actions\/exec');\nconst github = require('@actions\/github');\n\nconst setupGit = async () =&gt; {\n  await exec.exec(`git config --global user.name \"gh-automation\"`);\n  await exec.exec(`git config --global user.email \"gh-automation@email.com\"`);\n};\n\nconst validateBranchName = ({ branchName }) =&gt;\n  \/^[a-zA-Z0-9_\\-\\.\\\/]+$\/.test(branchName);\n\nconst validateDirectoryName = ({ dirName }) =&gt;\n  \/^[a-zA-Z0-9_\\-\\\/]+$\/.test(dirName);\n\nconst setupLogger = ({ debug, prefix } = { debug: false, prefix: '' }) =&gt; ({\n  debug: (message) =&gt; {\n    if (debug) {\n      core.info(`DEBUG ${prefix}${prefix ? ' : ' : ''}${message}`);\n    }\n  },\n  info: (message) =&gt; {\n    core.info(`${prefix}${prefix ? ' : ' : ''}${message}`);\n  },\n  error: (message) =&gt; {\n    core.error(`${prefix}${prefix ? ' : ' : ''}${message}`);\n  },\n});\n\nasync function run() {\n  const baseBranch = core.getInput('base-branch', { required: true });\n  const headBranch = core.getInput('head-branch', { required: true });\n  const ghToken = core.getInput('gh-token', { required: true });\n  const workingDir = core.getInput('working-directory', { required: true });\n  const debug = core.getBooleanInput('debug');\n  const logger = setupLogger({ debug, prefix: '[js-dependency-update]' });\n\n  const commonExecOpts = {\n    cwd: workingDir,\n  };\n  \n  core.setSecret(ghToken);\n\n  logger.debug('Validating inputs base-branch, head-branch, working-directory');\n\n  if (!validateBranchName({ branchName: baseBranch })) {\n    core.setFailed(\n      'Invalid base-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'\n    );\n    return;\n  }\n\n  if (!validateBranchName({ branchName: headBranch })) {\n    core.setFailed(\n      'Invalid head-branch name. Branch names should include only characters, numbers, hyphens, underscores, dots, and forward slashes.'\n    );\n    return;\n  }\n\n  if (!validateDirectoryName({ dirName: workingDir })) {\n    core.setFailed(\n      'Invalid working directory name. Directory names should include only characters, numbers, hyphens, underscores, and forward slashes.'\n    );\n    return;\n  }\n\n  logger.debug(`Base branch is ${baseBranch}`);\n  logger.debug(`Head branch is ${headBranch}`);\n  logger.debug(`Working directory is ${workingDir}`);\n\n  logger.debug('Checking for package updates');\n  await exec.exec('npm update', [], {\n    ...commonExecOpts,\n  });\n\n  const gitStatus = await exec.getExecOutput(\n    'git status -s package*.json',\n    [],\n    {\n      ...commonExecOpts,\n    }\n  );\n\n  let updatesAvailable = false;\n\n  if (gitStatus.stdout.length &gt; 0) {\n    updatesAvailable = true;\n\n    logger.debug('There are updates available!');\n    logger.debug('Setting up git');\n    await setupGit();\n\n    logger.debug('Committing and pushing package*.json changes');\n    await exec.exec(`git checkout -b ${headBranch}`, [], {\n      ...commonExecOpts,\n    });\n    await exec.exec(`git add package.json package-lock.json`, [], {\n      ...commonExecOpts,\n    });\n    await exec.exec(`git commit -m \"chore: update dependencies`, [], {\n      ...commonExecOpts,\n    });\n    await exec.exec(`git push -u origin ${headBranch} --force`, [], {\n      ...commonExecOpts,\n    });\n\n    logger.debug('Fetching octokit API');\n    const octokit = github.getOctokit(ghToken);\n\n    try {\n      logger.debug(`Creating PR using head branch ${headBranch}`);\n\n      await octokit.rest.pulls.create({\n        owner: github.context.repo.owner,\n        repo: github.context.repo.repo,\n        title: `Update NPM dependencies`,\n        body: `This pull request updates NPM packages`,\n        base: baseBranch,\n        head: headBranch,\n      });\n    } catch (e) {\n      logger.error(\n        'Something went wrong while creating the PR. Check logs below.'\n      );\n      core.setFailed(e.message);\n      logger.error(e);\n    }\n  } else {\n    logger.info('No updates at this point in time.');\n  }\n\n  logger.debug(`Setting updates-available output to ${updatesAvailable}`);\n  core.setOutput('updates-available', updatesAvailable);\n}\n\nrun();<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Understanding the JavaScript Action<\/h3>\n<p class=\"wp-block-paragraph\">1. Using @actions toolkit:<\/p>\n<pre class=\"wp-block-code\"><code>const core = require('@actions\/core');\nconst exec = require('@actions\/exec');\nconst github = require('@actions\/github');<\/code><\/pre>\n<p class=\"wp-block-paragraph\">GitHub provides these packages for common tasks:<\/p>\n<ul class=\"wp-block-list\"><li>@actions\/core &#8211; Get inputs, set outputs, log messages<\/li><li>@actions\/exec &#8211; Run shell commands<\/li><li>@actions\/github &#8211; Interact with GitHub API<\/li><\/ul>\n<p class=\"wp-block-paragraph\">2. Getting inputs:<\/p>\n<pre class=\"wp-block-code\"><code>const baseBranch = core.getInput('base-branch', { required: true });\nconst workingDir = core.getInput('working-directory', { required: true });\nconst debug = core.getBooleanInput('debug');<\/code><\/pre>\n<p class=\"wp-block-paragraph\">3. Setting outputs:<\/p>\n<pre class=\"wp-block-code\"><code>core.setOutput('updates-available', updatesAvailable);<\/code><\/pre>\n<p class=\"wp-block-paragraph\">4. Marking secrets:<\/p>\n<pre class=\"wp-block-code\"><code>core.setSecret(ghToken);<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This masks the token in logs so it never appears in plain text.<\/p>\n<p class=\"wp-block-paragraph\">5. Running commands:<\/p>\n<pre class=\"wp-block-code\"><code>await exec.exec('npm update', [], {\n  cwd: workingDir,\n});<\/code><\/pre>\n<p class=\"wp-block-paragraph\">6. Using GitHub API:<\/p>\n<pre class=\"wp-block-code\"><code>const octokit = github.getOctokit(ghToken);\n\nawait octokit.rest.pulls.create({\n  owner: github.context.repo.owner,\n  repo: github.context.repo.repo,\n  title: `Update NPM dependencies`,\n  body: `This pull request updates NPM packages`,\n  base: baseBranch,\n  head: headBranch,\n});<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This creates a pull request programmatically!<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/js-dependency-update\/package.json<\/p>\n<pre class=\"wp-block-code\"><code>{\n  \"name\": \"js-dependency-update\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" &amp;&amp; exit 1\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"type\": \"commonjs\",\n  \"dependencies\": {\n    \"@actions\/core\": \"^1.11.1\",\n    \"@actions\/exec\": \"^1.1.1\",\n    \"@actions\/github\": \"^6.0.1\"\n  }\n}<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Using the JavaScript Action<\/h3>\n<p class=\"wp-block-paragraph\">File: workflows\/17-2-custom-actions-js.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: 17 - 2 - Custom Actions - JS\nrun-name: 17 - 2 - Custom Actions - JS | BB - ${{ inputs.base-branch }} TB - ${{ inputs.target-branch }} WD - ${{ inputs.working-dir }}\n\non:\n  workflow_dispatch:\n    inputs:\n      base-branch:\n        description: 'Base branch for the dependency update'\n        type: string\n        default: main\n      target-branch:\n        description: 'Target branch for the dependency update'\n        type: string\n        default: update-dependencies\n      working-dir:\n        description: 'Working directory for the dependency update'\n        type: string\n        default: 17-custom-actions\/react-app\n      debug:\n        description: 'Enable debug mode for the dependency update'\n        type: boolean\n        default: false\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  dependency-update:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      \n      - name: Check for dependency updates\n        id: update-deps\n        uses: .\/.github\/actions\/js-dependency-update\n        with:\n          base-branch: ${{ inputs.base-branch }}\n          head-branch: ${{ inputs.target-branch }}\n          working-directory: ${{ inputs.working-dir }}\n          gh-token: ${{ secrets.GITHUB_TOKEN }}\n          debug: ${{ inputs.debug }}\n      \n      - name: Print custom action output\n        run: |\n          echo \"Updates available: ${{ steps.update-deps.outputs.updates-available }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\"><strong>Important:<\/strong> Notice the permissions section:<\/p>\n<pre class=\"wp-block-code\"><code>permissions:\n  contents: write\n  pull-requests: write<\/code><\/pre>\n<p class=\"wp-block-paragraph\">JavaScript actions that interact with GitHub need explicit permissions.<\/p>\n<h3 class=\"wp-block-heading\">Type 3: Docker Actions<\/h3>\n<p class=\"wp-block-paragraph\">Docker actions run in containers. They&#8217;re perfect when you need specific tools or languages that aren&#8217;t available in GitHub runners.<\/p>\n<p class=\"wp-block-paragraph\">I built a Docker action that pings a URL to check if it&#8217;s reachable.<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/docker-ping-url\/action.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: Ping URL\ndescription: Ping URL until maximum trials have exceeded. If result is not 200 until then, fails the action.\n\ninputs:\n  url:\n    description: URL to ping.\n    required: true\n  max_trials:\n    description: Maximum number of trials until action fails.\n    default: '10'\n    required: false\n  delay:\n    description: Delay in seconds between trials.\n    default: '5'\n    required: false\n\noutputs:\n  url-reachable:\n    description: Whether the URL is reachable\n\nruns:\n  using: docker\n  image: Dockerfile<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Instead of composite or node20, we use docker and point to a Dockerfile.<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/docker-ping-url\/Dockerfile<\/p>\n<pre class=\"wp-block-code\"><code>FROM python:alpine3.19\n\nWORKDIR \/app\n\nCOPY requirements.txt .\n\nRUN python -V\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nCMD [ \"python\", \"\/app\/main.py\" ]<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This creates a Python environment with the dependencies I need.<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/docker-ping-url\/requirements.txt<\/p>\n<pre class=\"wp-block-code\"><code>certifi==2023.11.17\ncharset-normalizer==3.3.2\nidna==3.6\nrequests==2.31.0\nurllib3==2.1.0<\/code><\/pre>\n<p class=\"wp-block-paragraph\">File: actions\/docker-ping-url\/main.py<\/p>\n<pre class=\"wp-block-code\"><code>import os\nimport requests\nimport time\n\n\ndef set_output(file_path, key, value):\n    with open(file_path, 'a') as file:\n        print(f'{key}={value}', file=file)\n\n\ndef ping_url(url, delay, max_trials):\n    trials = 0\n\n    while trials &lt; max_trials:\n        try:\n            response = requests.get(url)\n            if response.status_code == 200:\n                print(f\"Website {url} is reachable.\")\n                return True\n        except requests.ConnectionError:\n            print(f\"Website {url} is unreachable. Retrying in {delay} seconds...\")\n            time.sleep(delay)\n            trials += 1\n        except requests.exceptions.MissingSchema:\n            print(f\"Invalid URL format: {url}. Make sure the URL has a valid schema (e.g., http:\/\/ or https:\/\/)\")\n            return False\n    \n    return False\n\n\ndef run():\n    website_url = os.getenv(\"INPUT_URL\")\n    delay = int(os.getenv(\"INPUT_DELAY\"))\n    max_trials = int(os.getenv(\"INPUT_MAX_TRIALS\"))\n\n    website_reachable = ping_url(website_url, delay, max_trials)\n\n    set_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)\n    \n    if not website_reachable:\n        raise Exception(f\"Website {website_url} is malformed or unreachable.\")\n\n    print(f\"Website {website_url} is reachable.\")\n\n\nif __name__ == \"__main__\":\n    run()<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Understanding Docker Actions<\/h3>\n<p class=\"wp-block-paragraph\">1. Input variables become environment variables:<\/p>\n<p class=\"wp-block-paragraph\">GitHub automatically converts inputs to environment variables with INPUT_ prefix:<\/p>\n<ul class=\"wp-block-list\"><li>url input \u2192 INPUT_URL environment variable<\/li><li>max_trials \u2192 INPUT_MAX_TRIALS<\/li><li>delay \u2192 INPUT_DELAY<\/li><\/ul>\n<pre class=\"wp-block-code\"><code>website_url = os.getenv(\"INPUT_URL\")\ndelay = int(os.getenv(\"INPUT_DELAY\"))<\/code><\/pre>\n<p class=\"wp-block-paragraph\">2. Setting outputs:<\/p>\n<pre class=\"wp-block-code\"><code>def set_output(file_path, key, value):\n    with open(file_path, 'a') as file:\n        print(f'{key}={value}', file=file)\n\nset_output(os.getenv('GITHUB_OUTPUT'), 'url-reachable', website_reachable)<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Same concept as JavaScript &#8211; write to $GITHUB_OUTPUT file.<\/p>\n<p class=\"wp-block-paragraph\">3. The .dockerignore file:<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/docker-ping-url\/.dockerignore<\/p>\n<pre class=\"wp-block-code\"><code>venv<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This prevents copying unnecessary files into the Docker image.<\/p>\n<h3 class=\"wp-block-heading\">Using the Docker Action<\/h3>\n<p class=\"wp-block-paragraph\">File: workflows\/17-3-custom-actions-docker.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: 17 - 3 - Custom Actions - Docker\n\non:\n  workflow_dispatch:\n    inputs:\n      url:\n        description: URL to ping for reachability test.\n        type: choice\n        options:\n          - 'https:\/\/www.google.com'\n          - 'https:\/\/www.invalid-aidfghaidgfiazgd.com'\n          - 'invalid-aidfghaidgfiazgd'\n      max_trials:\n        description: Maximum trials until action fails.\n        default: '10'\n        required: false\n      delay:\n        description: Delay in seconds between trials.\n        default: '5'\n        required: false\n\njobs:\n  ping-url:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      \n      - name: Ping URL\n        id: ping-url\n        uses: .\/.github\/actions\/docker-ping-url\n        with:\n          url: ${{ inputs.url }}\n          max_trials: ${{ inputs.max_trials }}\n          delay: ${{ inputs.delay }}\n      \n      - name: Print output from ping url\n        run: |\n          echo \"URL reachable: ${{ steps.ping-url.outputs.url-reachable }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">I can test with valid and invalid URLs to see how the action behaves.<\/p>\n<h3 class=\"wp-block-heading\">Comparing the Three Types<\/h3>\n<figure class=\"wp-block-table\"><table><thead><tr><th>Feature<\/th><th>Composite<\/th><th>JavaScript<\/th><th>Docker<\/th><\/tr><\/thead><tbody><tr><td>Language<\/td><td>YAML (workflow steps)<\/td><td>JavaScript (Node.js)<\/td><td>Any language<\/td><\/tr><tr><td>Complexity<\/td><td>Simple<\/td><td>Medium<\/td><td>Complex<\/td><\/tr><tr><td>Speed<\/td><td>Fast<\/td><td>Fast<\/td><td>Slow (builds image)<\/td><\/tr><tr><td>Dependencies<\/td><td>Actions marketplace only<\/td><td>npm packages<\/td><td>Anything in Docker<\/td><\/tr><tr><td>Best for<\/td><td>Combining existing steps<\/td><td>GitHub API interaction<\/td><td>Custom tools\/languages<\/td><\/tr><tr><td>Shell required?<\/td><td>Yes (in run steps)<\/td><td>No<\/td><td>N\/A<\/td><\/tr><\/tbody><\/table><\/figure>\n<h3 class=\"wp-block-heading\">When to Use Each Type<\/h3>\n<p class=\"wp-block-paragraph\">Use Composite when:<\/p>\n<ul class=\"wp-block-list\"><li>You&#8217;re combining existing actions\/steps<\/li><li>You don&#8217;t need custom logic<\/li><li>Speed is important<\/li><li>Example: Setup Node + Cache + Install dependencies<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Use JavaScript when:<\/p>\n<ul class=\"wp-block-list\"><li>You need to interact with GitHub API<\/li><li>You want complex conditional logic<\/li><li>You need to process data<\/li><li>Example: Create PRs, manage issues, analyze workflows<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Use Docker when:<\/p>\n<ul class=\"wp-block-list\"><li>You need specific tools not in GitHub runners<\/li><li>You&#8217;re using a language other than JavaScript<\/li><li>You need exact dependency versions<\/li><li>Example: Python scripts, Ruby gems, custom binaries<\/li><\/ul>\n<h3 class=\"wp-block-heading\">What I Learned Building These Actions<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Composite actions must specify shell:<\/strong> Every run: step needs shell: bash or shell: pwsh.<\/li><li><strong>JavaScript actions need node_modules:<\/strong> You must commit node_modules\/ or users can&#8217;t run your action. Add this to .gitignore exception:<\/li><li><strong>Docker actions are slow:<\/strong> Building the Docker image takes time. First run can take 2-3 minutes. Subsequent runs are faster if image is cached.<\/li><li><strong>Inputs are always strings:<\/strong> Even if you pass a boolean or number, it arrives as a string. Convert it:<\/li><li><strong>Use core.setSecret():<\/strong> Mask sensitive data in logs:<\/li><li><strong>Validate inputs early:<\/strong> Check inputs before doing work. My JavaScript action validates branch names and directory paths at the start.<\/li><li><strong>Outputs are optional:<\/strong> Not every action needs outputs. My composite action doesn&#8217;t have any because caching happens silently.<\/li><li><strong>Test locally first:<\/strong> For JavaScript actions, run node index.js with environment variables set. For Docker actions, build and run the image locally.<\/li><li><strong>Working directory matters:<\/strong> Composite actions inherit working directory from the job, but you can override it per step. JavaScript and Docker actions always run from the action directory.<\/li><li><strong>Error handling is critical:<\/strong> Always handle failures gracefully:<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Real-World Example: Combining Custom Actions<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I use all three in one workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: Complete CI\/CD with Custom Actions\n\non:\n  push:\n    branches: [main]\n\njobs:\n  setup-and-test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      \n      # Composite action\n      - name: Setup dependencies\n        uses: .\/.github\/actions\/composite-cache-deps\n        with:\n          node-version: 20.x\n          working-dir: .\/app\n      \n      - name: Test\n        run: npm test\n        working-directory: .\/app\n\n  check-dependencies:\n    runs-on: ubuntu-latest\n    needs: setup-and-test\n    permissions:\n      contents: write\n      pull-requests: write\n    steps:\n      - uses: actions\/checkout@v4\n      \n      # JavaScript action\n      - name: Update dependencies\n        uses: .\/.github\/actions\/js-dependency-update\n        with:\n          base-branch: main\n          head-branch: update-deps\n          working-directory: .\/app\n          gh-token: ${{ secrets.GITHUB_TOKEN }}\n\n  deploy-and-verify:\n    runs-on: ubuntu-latest\n    needs: setup-and-test\n    steps:\n      - uses: actions\/checkout@v4\n      \n      - name: Deploy application\n        run: echo \"Deploying...\"\n      \n      # Docker action\n      - name: Verify deployment\n        uses: .\/.github\/actions\/docker-ping-url\n        with:\n          url: https:\/\/my-app.example.com\n          max_trials: 15\n          delay: 10<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This workflow:<\/p>\n<ol class=\"wp-block-list\"><li>Uses composite action to cache and install dependencies<\/li><li>Uses JavaScript action to check for updates and create PR<\/li><li>Uses Docker action to verify deployment health<\/li><\/ol>","protected":false},"excerpt":{"rendered":"<p>After covering the fundamentals, now I want to share how to create your own reusable actions. This is where GitHub Actions becomes&hellip;<\/p>\n","protected":false},"author":2,"featured_media":75,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"5","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-custom-actions-github-composite-javascript-docker-chegini-dahsc\/","footnotes":""},"categories":[27,10],"tags":[30,8,35,29,36],"series":[28],"class_list":["post-73","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ci-cd","category-devops","tag-ci-cd","tag-devops","tag-docker","tag-github-actions","tag-javascript","series-github-actions"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/building-custom-actions-github-actions-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/73","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=73"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/73\/revisions"}],"predecessor-version":[{"id":74,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/73\/revisions\/74"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/75"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=73"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=73"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=73"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=73"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}