A failing workflow isn't the end of the world if you know how to read its trail. In this episode we enable debug logging, go straight into the runner via interactive SSH with tmate, then build custom actions based on TypeScript and Docker containers.

In episode 18 we automated release management and semantic versioning. As pipelines get more complex, one thing is guaranteed: a workflow fails at 2 AM. The ability to debug and fix a pipeline is just as important as the ability to build it. In this episode we learn to read failure trails, enable debug logging, enter the runner interactively, then produce our own custom actions so that repeated logic can be packaged and reused.
In this episode we discuss:
A tiered approach, starting from the cheapest:
Two repository secrets trigger far more detailed logs:
| Secret | Effect |
|---|---|
| ACTIONS_RUNNER_DEBUG | Runner diagnostic logs: job selection, action download, step execution |
| ACTIONS_STEP_DEBUG | Extra logs per step, including variable exports and action context |
Set both secrets to true in repository Settings → Secrets and variables → Actions. Extra logs appear as ##[debug] lines on every step. Once debugging is done, delete the secrets or change their values back to false — debug logs bloat the log output and slow the pipeline down.
When logs aren't enough — for example an error only appears during a certain interaction — we can connect directly to the live runner over SSH using mxschmitt/action-tmate@v3. Add a temporary step to the workflow:
name: Debug Runner
on: workflow_dispatch
jobs:
debug:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Buka sesi SSH interaktif
uses: mxschmitt/action-tmate@v3
with:
timeout-minutes: 30When this step runs, the log displays a tmate SSH address like ssh alice@proxy.tmate.io. From your laptop, you can log in and run ls, ps aux, or other commands directly on the runner to observe the real conditions. The screen prints the session status so you know when the runner is ready.
Warning
tmate opens full shell access to the runner for anyone holding the SSH address. Only enable it on a temporary workflow, never leave it on the main branch, and don't use it on a public repository with a self-hosted runner.
Once you're done, remove the tmate step from the workflow — otherwise every run will stop and wait for a connection until timeout.
Signs that logic needs to be packaged into an action:
There are three kinds of custom actions: JavaScript/TypeScript, Docker container, and composite. In this episode we focus on the first two; composite was already covered in episode 11.
A TypeScript action is compiled into a single dist/index.js file executed directly by the runner with the Node.js runtime. Because no image build is needed, execution is fast — suitable for processing logic.
Every action must have an action.yml at the action repository's root:
name: 'Compute Next Version'
description: 'Menghitung versi berikutnya dari commit message Conventional Commits'
author: 'Arman'
inputs:
release-type:
description: 'Jenis rilis: patch, minor, atau major'
required: true
prefix:
description: 'Prefix versi, misal v'
required: false
default: 'v'
outputs:
version:
description: 'Versi yang dihitung'
runs:
using: node20
main: dist/index.js
branding:
icon: 'tag'
color: 'purple'The source code is written in TypeScript, using @actions/core for inputs/outputs and @actions/github for GitHub context access:
import * as core from '@actions/core'
import * as github from '@actions/github'
async function run(): Promise<void> {
try {
const releaseType = core.getInput('release-type')
const prefix = core.getInput('prefix')
const ref = github.context.ref
const version = computeVersion(releaseType, ref)
core.setOutput('version', `${prefix}${version}`)
} catch (error) {
core.setFailed(error instanceof Error ? error.message : 'Terjadi error')
}
}
function computeVersion(releaseType: string, ref: string): string {
return `1.${releaseType === 'minor' ? 1 : 0}.0`
}
run()Explanation:
core.getInput() reads inputs declared in action.yml; core.setOutput() writes outputs the calling workflow can read.github.context provides access to context like repository, ref, and actor.core.setFailed() makes the action finish with a failed status and a clear message.The Node.js runner executes dist/index.js, so the source must be compiled before committing. Build with esbuild, then commit the result:
npm install --save-dev esbuild typescript @types/node
npx esbuild src/main.ts --bundle --platform=node --target=node20 \
--outfile=dist/index.js
git add dist/index.js
git commit -m "chore: build dist"Because the action is consumed via a tag or commit SHA, dist/index.js must always be updated and committed — if you forget, the action will run with stale code. Bundling with esbuild wraps @actions/core and @actions/github into one file, so action consumers don't need to npm install anything.
Tip
Install eslint to check the action code, and make sure the dist file is built on every source change. Many teams wrap the compilation into the action's own CI workflow so it never gets forgotten.
A Docker action runs inside a container — free to choose any language and dependencies, without worrying about the runner's runtime version. Great for tools that need their own image (for example a specific gcloud or kubectl version). The downside: execution is slower because the image has to be built or pulled first.
name: 'Run Cloud Migrations'
description: 'Menjalankan skema migrasi database di cloud'
inputs:
environment:
description: 'Target environment'
required: true
runs:
using: docker
image: Dockerfile
env:
ENVIRONMENT: ${{ inputs.environment }}The image is built from the Dockerfile, then the container runs the entrypoint:
FROM alpine:3.20
RUN apk add --no-cache python3 py3-pip
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]#!/bin/sh
set -euo pipefail
echo "Menjalankan migrasi untuk environment: ${INPUT_ENVIRONMENT}"
python3 /app/migrate.py --env "${INPUT_ENVIRONMENT}"Explanation:
INPUT_: the environment input becomes INPUT_ENVIRONMENT.set -euo pipefail stops the container on any error, which makes the action fail correctly.chmod +x), and make sure the file has no CRLF — set core.autocrlf in git to avoid mysterious not found errors.In this episode we discussed troubleshooting and custom action development:
##[debug] thanks to the ACTIONS_RUNNER_DEBUG and ACTIONS_STEP_DEBUG secrets.dist/index.js with @actions/core and @actions/github.INPUT_ prefix.All the automation puzzle pieces are now complete: triggers, jobs, artifacts, secrets, deploy, release, and now custom actions. In episode 20, the final episode, we tie everything into a complete end-to-end production-grade CI/CD pipeline case study. See you there!