GitHub Actions Interview Questions and Answers
Last updated:
Check out 45 of the most common GitHub Actions interview questions, then take an AI-powered practice interview
Q1What is GitHub Actions and what problems does it solve?
BasicFundamentals
Answer
GitHub Actions is GitHub's native CI/CD and automation platform, launched in 2018 and reaching general availability in 2019. It solves the integration friction that plagued earlier CI systems: instead of running Jenkins on your own infrastructure or wiring up CircleCI as an external app, the CI lives in the same place as the code, the issues, the PRs, and the package registry. Workflows are defined in YAML files under `.github/workflows/`, triggered by GitHub events (push, pull_request, schedule, manual dispatch), and run on GitHub-hosted virtual machines or your own self-hosted runners.
The platform also ships a Marketplace of reusable Actions, pre-built building blocks like `actions/checkout`, `actions/setup-node`, `aws-actions/configure-aws-credentials`, so you compose pipelines instead of writing shell scripts from scratch. For most Indian startups in 2026, it is the cheapest, fastest path from `git push` to a deployed application. As of 2026 it is the default CI for most new GitHub-hosted projects, and the teams still on Jenkins are typically the ones with an existing investment rather than new adopters.
Mechanically, a `git push` emits an event; the Actions service reads every YAML file under `.github/workflows/` at that commit, filters them by the `on:` block, and queues one workflow run per match. A runner agent (the open-source `actions/runner` binary) long-polls GitHub for work, leases a job, executes each step in a fresh shell process, and streams logs back over HTTPS. Interviewers usually follow up on two things.
First, billing: Linux minutes bill at a 1x multiplier, Windows at 2x, macOS at 10x, and public repositories run free on standard runners, which is why a careless matrix on macOS is the classic surprise invoice. Second, the limits: a job caps at 6 hours, a workflow run at 35 days including queue time, and the `GITHUB_TOKEN` is rate limited to 1,000 REST API requests per hour per repository, so a script that paginates issues in a loop will start returning HTTP 403 rate-limit errors long before the job times out.
Key Points
- Native to GitHub, no separate service to wire up
- YAML workflows in `.github/workflows/`
- Marketplace of reusable Actions for common tasks
- Generous free tier (2000 mins/month for private repos) covers most early startups
Q2What is the basic structure of a GitHub Actions workflow file?
BasicWorkflow Syntax
Answer
A workflow is a YAML file with three required top-level keys: `name` (display label in the Actions UI), `on` (the events that trigger this workflow), and `jobs` (the units of work). Each job runs on a `runs-on` runner image, has a `steps` list of either `uses:` actions or `run:` shell commands, and can declare `needs:` to wait for other jobs. Workflows live in `.github/workflows/` at the repo root, anything in that folder is automatically picked up by GitHub.
A single repo can have many workflow files; they run independently and have their own triggers. Common organization: one workflow per concern (`ci.yml`, `deploy.yml`, `security.yml`, `release.yml`) rather than one giant file with conditional steps, keeps the YAML readable and the Actions UI navigable. Beyond the three required keys, the ones you will see in every mature repo are `permissions:` (scope down `GITHUB_TOKEN`), `concurrency:` (cancel superseded runs), `env:` (workflow-wide variables), and `defaults: run: shell` / `working-directory` so you stop repeating `working-directory: ./apps/api` on twenty steps.
Two parsing gotchas trip people up. YAML 1.1 treats the bare word `on` as boolean true, so external linters flag `on:` as the key `true:`; GitHub's own parser handles it, but `yamllint` needs `truthy: false` or you quote it as `"on":`. And a malformed file fails before anything runs, surfacing as a red "Invalid workflow file" banner with a message like `(Line: 12, Col: 7): Unexpected value 'runs-on'`, which never appears in the job logs because no job was ever created. Interviewers often probe whether you know that `schedule` and `workflow_dispatch` triggers are only honoured once the workflow file exists on the repository's default branch, which is why a cron workflow tested on a feature branch appears to do nothing.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Key Points
- Files live in `.github/workflows/*.yml`
- Top-level keys: name, on, jobs
- Each job has runs-on + steps
Q3What are the most common workflow triggers?
BasicTriggers
Answer
The `on:` key accepts a single event, a list, or a map of events with filters. The five triggers you will see in 90% of pipelines: (1) `push`, runs on every push, filterable by branches/tags/paths, (2) `pull_request`, runs on PR open/sync/reopen, the workhorse for CI gates, (3) `schedule`, cron-based, for nightly builds, dependency updates, stale-issue cleanup, (4) `workflow_dispatch`, manual trigger with optional inputs, the standard way to do click-to-deploy, (5) `workflow_call`, makes the workflow reusable from other workflows. Less common but useful: `release` (fires on release publish for changelog automation), `issues` (auto-label, auto-close stale), `repository_dispatch` (external webhooks from a CMS, marketing tool, or third-party service). `schedule` runs in UTC and may be delayed during GitHub's peak hours, never rely on the exact minute for time-sensitive jobs.
Filter rules that catch people out: `branches` and `branches-ignore` are mutually exclusive on the same event, and so are `paths` and `paths-ignore`, so you express negation inside one list with a `!` prefix rather than mixing both keys. `pull_request` defaults to only three activity types (`opened`, `synchronize`, `reopened`), so if you want CI to fire when a draft PR is marked ready you must add `ready_for_review` explicitly. Path filters do not apply to `workflow_dispatch` or `schedule` at all. A scheduled workflow is automatically disabled after 60 days with no repository activity, and GitHub emails the owner rather than failing loudly, which is the usual explanation for "our nightly job silently stopped two months ago".
The full event payload is available as `github.event`, so `${{ github.event.pull_request.draft }}` or `${{ github.event.head_commit.message }}` lets you branch on details the top-level contexts do not expose. Senior interviewers follow up by asking which trigger fires for a fork PR and whether it can see secrets, which is the `pull_request` versus `pull_request_target` distinction.
on:
push:
branches: [main, 'release/*']
paths: ['src/**', '!**.md']
pull_request:
types: [opened, synchronize, ready_for_review]
schedule:
- cron: '0 2 * * *' # 2 AM UTC daily
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
Q4What is the difference between a job and a step?
BasicWorkflow Syntax
Answer
A job is a unit of work that runs on a single runner machine. All steps inside a job share the same filesystem, environment variables, and runner. A step is a single command, either `uses:` (invokes a reusable Action) or `run:` (executes a shell command).
Jobs run in parallel by default; steps inside a job run sequentially. If you need to share data across jobs you must use `artifacts` (upload/download files) or `outputs` (small string values), the filesystem is wiped between jobs. This is the most common newcomer mistake: assuming `cd /tmp && do-thing` in job A is visible in job B.
The isolation is total: a separate VM, a separate `$HOME`, a separate Docker daemon, and a fresh `actions/checkout` needed in every job that touches source. Failure semantics differ as well. A failing step aborts the remaining steps unless it sets `continue-on-error: true`, while a failing job skips every job that names it in `needs:` unless the dependent job uses `if: always()` or `if: ${{ !cancelled() }}`.
Job outputs are declared under `outputs:` at job level and read as `needs.<job>.outputs.<name>`; they are strings, they are blanked if they contain a masked secret, and anything structured has to be JSON-encoded and parsed back with `fromJSON()`. Billing rounds every job up to a whole minute, so splitting one 70-second job into five tiny ones bills five minutes. The follow-up interviewers like: why is a heavily split pipeline often slower overall? Because per-job VM provisioning, checkout, dependency install and cache restore all repeat for every job.
jobs:
build:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.meta.outputs.sha }} # strings only: 1 MB per output, 50 MB per run
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
deploy:
needs: build # different VM: nothing on disk survives
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- run: ./deploy.sh dist/ ${{ needs.build.outputs.sha }}
Key Points
- Job = one runner machine, isolated filesystem
- Steps inside a job share state
- Jobs run in parallel unless `needs:` is set
- Cross-job state requires artifacts or outputs
Q5What are GitHub-hosted runners and what's available?
BasicRunners
Answer
GitHub-hosted runners are fresh VMs that GitHub spins up for each job. The defaults: `ubuntu-latest` (currently Ubuntu 24.04), `windows-latest`, `macos-latest`. Linux runners are 2-vCPU/7 GB by default, fine for most builds, slow for big monorepos.
In 2026 GitHub also offers larger runners (4/8/16/32/64 vCPU) and ARM64 runners (`ubuntu-24.04-arm`) at extra cost. macOS runners are 5-10× more expensive, only use them if you actually need to build for Apple platforms. Each job gets a clean VM, which is a feature, not a bug: you cannot accidentally pollute the environment from a previous run. Three behaviours catch teams out.
First, `-latest` labels move: `ubuntu-latest` was migrated from 22.04 to 24.04 during 2025, and a pinned apt package or a changed system OpenSSL can break a pipeline overnight, which is why production workflows pin `ubuntu-24.04` explicitly and watch the `actions/runner-images` changelog. Second, the images are large but the disk is not infinite: an `ubuntu-24.04` runner typically reports somewhere in the mid-to-high twenties of gigabytes free on `/` once the preinstalled toolchains are accounted for, and that figure moves with every image release, so check it with `df -h /` rather than trusting a number. Docker-heavy builds still hit `no space left on device` and teams prune images or run a free-disk-space step first.
Third, runners get ephemeral public egress IPs from Azure ranges, so a database or artifact store behind an IP allowlist will reject them and you need self-hosted runners or a static-IP NAT proxy. Inside the job, `runner.os`, `runner.arch` and `runner.temp` are the contexts you branch on when one workflow has to serve several images.
jobs:
build:
strategy:
matrix:
runner:
- ubuntu-24.04 # pinned, not -latest
- ubuntu-24.04-arm # ARM64, cheaper per minute
- macos-14 # Apple silicon, bills at 10x
runs-on: ${{ matrix.runner }}
steps:
- run: |
echo "os=${{ runner.os }} arch=${{ runner.arch }}"
df -h / # check real free space; it changes per image release
Key Points
- Fresh VM per job, no state leakage
- Default Linux: 2 vCPU, 7 GB RAM
- Larger runners available (paid) for heavy builds
- ARM64 Linux runners GA in 2024-25, popular for cost savings in 2026
Q6How do you pass data between steps?
BasicWorkflow Syntax
Answer
Two mechanisms: (1) environment variables via `$GITHUB_ENV`, append `KEY=value` to that file and the variable is available in all subsequent steps, (2) step outputs via `$GITHUB_OUTPUT`, append `name=value` to that file inside a step that has an `id:`, then reference it as `${{ steps.<id>.outputs.<name> }}`. Note: both replaced older workflow commands, but for different reasons. `::set-env` and `::add-path` were deprecated in November 2020 as a security fix, because a command string appearing in build output could set an arbitrary environment variable. `::set-output` was deprecated separately in October 2022, for reasons unrelated to that vulnerability. Neither belongs in new code.
Both are plain files on the runner, so you always append with `>>` and never clobber with `>`. Multi-line values need the heredoc delimiter form, otherwise the step fails with `Invalid format` because the runner parses each line as its own `key=value` pair; pick a random delimiter when the value is untrusted, since a payload containing your delimiter can inject extra variables. Ordering matters: a variable written to `$GITHUB_ENV` is not visible inside the same step, only in later ones, because the runner reads the file after the step process exits.
Values in `$GITHUB_OUTPUT` live only within the job, so to reach another job you promote them to `jobs.<id>.outputs` and read `needs.<job>.outputs.<name>`. There are two more files worth knowing: `$GITHUB_PATH` prepends a directory to `PATH` for subsequent steps, and `$GITHUB_STEP_SUMMARY` takes Markdown that renders on the run summary page, which is where you put test tables and coverage deltas instead of making people read logs.
- name: Compute version
id: version
run: |
VERSION=$(date +%Y%m%d-%H%M%S)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "BUILD_VERSION=$VERSION" >> $GITHUB_ENV
- name: Use it
run: echo "Building ${{ steps.version.outputs.version }}"
# $BUILD_VERSION is also available as an env var here
- name: Multi-line value needs a heredoc delimiter
run: |
{
echo 'CHANGELOG<<GH_EOF_7f3a'
git log --oneline -20
echo 'GH_EOF_7f3a'
} >> $GITHUB_ENV
- name: Render a summary instead of burying it in logs
run: echo "### Build $BUILD_VERSION passed" >> $GITHUB_STEP_SUMMARY
Q7How do you use secrets in a workflow?
BasicSecrets
Answer
Define secrets in repo or organization Settings → Secrets and variables → Actions, then reference them as `${{ secrets.MY_SECRET }}` inside the workflow. GitHub masks secret values in logs automatically, if `DEPLOY_KEY` is `abc123`, every occurrence of `abc123` in step output is replaced with `***`. Critical safety rules: (1) Never echo secrets to stdout, masking is not foolproof if you transform the value (base64, jq, etc), (2) Workflows triggered by `pull_request` from a fork do NOT receive secrets by default, this is the central security boundary that prevents random forks from stealing your production keys, (3) Use `secrets` scoped to environments (e.g. production has a different DEPLOY_KEY than staging) so a staging compromise does not leak prod credentials.
Details a senior interviewer expects: repository *variables* (`${{ vars.NAME }}`) exist alongside secrets for non-sensitive config, so an API base URL should not be a secret just because it lives in the same settings page. Precedence runs environment secrets over repository secrets over organization secrets, and organization secrets can be restricted to selected repositories. A reusable workflow receives nothing automatically, you either list secrets under `secrets:` or pass `secrets: inherit`, and `inherit` hands over everything, which is the wrong default when the called workflow belongs to another team.
Masking is literal substring replacement of the exact stored value, so a multi-line private key is masked line by line but `echo "$KEY" | base64` or a JSON-escaped copy prints in the clear, and any value you construct at runtime needs `::add-mask::` to be protected. Secrets are also unavailable in `if:` expressions at workflow level, so the usual pattern is to copy one into a job-level `env:` and test that instead. Finally, deleting a secret does not scrub it from old run logs, so a leaked value has to be rotated at the provider, not merely removed from GitHub.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: |
curl -X POST https://api.example.com/deploy \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"
Q8What is the difference between `uses` and `run` in a step?
BasicWorkflow Syntax
Answer
`run:` executes a shell command (bash on Linux/macOS, PowerShell on Windows by default). `uses:` invokes a reusable Action, either a public one from the Marketplace (`actions/checkout@v4`), a local one in the same repo (`./.github/actions/my-action`), or a Docker container. Actions encapsulate complex logic so you don't reimplement it: `actions/setup-node` handles installing the right Node version, configuring npm, and caching dependencies in 3 lines vs the 30 lines of bash it would replace. The mechanics behind each are worth knowing.
With no `shell:` key at all, a `run:` step on Linux and macOS is written to a temporary script and executed with the implicit default `bash -e {0}`, which means `-e` is on but `pipefail` is not, so `npm test | tee out.log` reports success even when the tests fail. Writing `shell: bash` explicitly is the fix on its own: that form invokes `bash --noprofile --norc -eo pipefail {0}`, so the pipeline failure propagates. Setting it once under `defaults: run: shell: bash` applies it to every step in the workflow.
A multi-line `run:` is one process, so `cd apps/api` carries across lines of that step but not into the next step, where `working-directory:` is the right tool. `uses:` accepts four forms: `owner/repo@ref`, `owner/repo/sub/dir@ref` for an action nested in a repository, `./path` for a local action, and `docker://image:tag`. A local action requires `actions/checkout` to have run first and is read from the checked-out commit, so a pull request can change its code, which is exactly why you never combine local actions with `pull_request_target`. The `uses:` value must be a literal, you cannot interpolate `${{ }}` into it, which is why version selection is done with `if:` on parallel steps rather than a variable ref.
defaults:
run:
shell: bash # explicit bash = -eo pipefail; the implicit default is not
working-directory: apps/api
steps:
- uses: actions/checkout@v4 # Marketplace action
- run: | # one process, one shell
npm ci
npm test | tee test.log # pipefail is on, so a test failure fails the step
- uses: ./.github/actions/my-deploy # local action, needs checkout first
- uses: docker://ghcr.io/aquasecurity/trivy:0.58.0
Q9How do you check out the source code in a workflow?
BasicWorkflow Syntax
Answer
Use `actions/checkout@v4`. It is the most-used Action in the entire ecosystem, almost every workflow's first step. By default it does a shallow clone (depth 1) of the commit that triggered the workflow.
Common options: `fetch-depth: 0` to get full git history (required for tools like `lerna` or `nx affected`), `ref: <branch>` to check out a different branch, `token:` to use a custom PAT (e.g. when you need to push back to the repo from inside the action, the default `GITHUB_TOKEN` cannot trigger other workflows, so an external PAT is needed). Under the hood it is not a plain `git clone`: the action configures an `http.extraheader` carrying a basic-auth credential derived from the token, fetches a single commit, and by default leaves that credential in `.git/config` for the rest of the job (`persist-credentials: true`). That is convenient for pushing back, and it is also how a malicious build script or a compromised dependency exfiltrates a write-capable token, so hardened workflows set `persist-credentials: false` and pass a token only to the step that needs it.
On a `pull_request` event the action checks out the ephemeral *merge commit* at `refs/pull/N/merge`, not your branch head, so `git rev-parse HEAD` does not equal `github.event.pull_request.head.sha` and anything tagging images by commit produces a SHA that will not exist after merge. Other traps: a shallow clone breaks `git describe`, changelog generation and `nx affected`, so those need `fetch-depth: 0`; a second `actions/checkout` with `path:` and a token pulls in a sibling repository; and `sparse-checkout:` is the cheap monorepo win, fetching only the directories a job actually needs instead of a multi-gigabyte tree.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for nx affected, lerna, semantic-release
submodules: recursive # if you use git submodules
persist-credentials: false # do not leave a token in .git/config
# Monorepo: fetch only what this job builds
- uses: actions/checkout@v4
with:
sparse-checkout: |
apps/api
packages/shared
# A sibling private repo alongside the main checkout
- uses: actions/checkout@v4
with:
repository: goodspaceai/infra
path: infra
token: ${{ secrets.INFRA_READ_PAT }}
Q10How do you control when a workflow or job runs using `if` conditions?
BasicWorkflow Syntax
Answer
Add an `if:` key at the workflow, job, or step level. Conditions use GitHub's expression syntax, limited variants of JS-like booleans. Common examples: `if: github.event_name == 'push'`, `if: github.ref == 'refs/heads/main'`, `if: success() && needs.test.result == 'success'`.
The functions `success()`, `failure()`, `cancelled()`, `always()` are the standard way to handle step-level error flow. A step set to `if: always()` runs even if a previous step failed, essential for upload-artifacts-on-failure or notify-slack-on-failure patterns. The mechanics people get wrong: inside `if:` you may usually omit the `${{ }}` wrapper, but when you do use it the entire value must be one expression, because `if: ${{ a }} && ${{ b }}` collapses into a non-empty string and every non-empty string is truthy.
The one case where the wrapper is mandatory is a condition starting with `!`, since `!` is YAML's tag indicator and a plain scalar cannot begin with it, a bare `!cancelled()` produces an invalid workflow file and you must write `if: ${{ !cancelled() }}`. The same truthiness rule means a typo such as `if: githb.ref == 'refs/heads/main'` evaluates to false silently instead of erroring. Every step and job carries an implicit `success()` check, and writing any `if:` removes it, so `if: env.DEPLOY == 'true'` will happily run after an earlier failure unless you write `if: success() && env.DEPLOY == 'true'`. `always()` also runs during cancellation and can keep a cancelled run alive, so cleanup steps should generally use `if: ${{ !cancelled() }}` instead.
At job level, `needs.<job>.result` is one of `success`, `failure`, `cancelled` or `skipped`, and skipped propagates down the chain, which is what breaks required status checks in path-filtered pipelines. The helper functions `contains()`, `startsWith()`, `endsWith()` and `fromJSON()` cover most real conditions, and `contains(github.event.pull_request.labels.*.name, 'run-e2e')` is the standard label gate for expensive jobs.
jobs:
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
- name: Notify on failure
if: failure()
run: curl -X POST $SLACK_WEBHOOK -d 'Deploy failed'
Q11What is the `GITHUB_TOKEN` and how is it different from a Personal Access Token?
BasicAuthentication
Answer
`GITHUB_TOKEN` is an auto-generated, ephemeral token unique to each workflow run. It is provisioned at job start, expires at job end, and is scoped to the repo where the workflow runs. You access it via `${{ secrets.GITHUB_TOKEN }}` or, for permission-aware actions, it is automatically injected.
A Personal Access Token (PAT) is a long-lived credential tied to a user account, much broader in scope, and a frequent source of security incidents when leaked. The big practical difference: actions performed using `GITHUB_TOKEN` (creating a commit, opening a PR) do NOT trigger downstream workflows. This is by design, to prevent recursive workflow loops.
If you need a downstream workflow to fire (e.g. semantic-release tagging a commit that should trigger a deploy), use a PAT or a GitHub App token instead. Two more points a senior interviewer probes. The token's default permission set is a repository or organization setting: repositories created after February 2023 default to read-only `contents` and nothing else, older ones may still default to permissive write, so identical YAML returns `Error: Resource not accessible by integration` in one repo and works in another.
The correct fix is an explicit `permissions:` block, not reaching for a PAT. Second, `GITHUB_TOKEN` is rate limited to 1,000 REST requests per hour per repository and cannot reach other repositories at all, so reading a private sibling repo needs a fine-grained PAT, a deploy key, or the option most teams standardised on by 2026, a GitHub App installation token minted at runtime with `actions/create-github-app-token`. App tokens expire in an hour, are scoped per installation, belong to a bot rather than an employee who might leave, and unlike `GITHUB_TOKEN` their pushes do trigger downstream workflows, which is the clean way to break the no-recursion restriction without a personal credential.
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 1,000 REST req/hour/repo
run: gh pr edit "${{ github.event.pull_request.number }}" --add-label needs-review
release:
runs-on: ubuntu-latest
steps:
# App token: 1-hour lifetime, and its pushes DO trigger other workflows
- uses: actions/create-github-app-token@v1
id: app
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- run: npx semantic-release
Key Points
- Ephemeral, repo-scoped, auto-generated per job
- Actions using GITHUB_TOKEN do not trigger other workflows
- Use PAT or GitHub App for cross-workflow triggering
- Set explicit `permissions:` to narrow scope (principle of least privilege)
Q12How do you store and reuse a build artifact across jobs?
BasicArtifacts
Answer
Use `actions/upload-artifact@v4` in the producing job and `actions/download-artifact@v4` in the consuming job. Artifacts are zipped, uploaded to GitHub-managed storage, and live for 90 days by default (configurable per-artifact via `retention-days:`). Note the v4 jump in 2024: it changed the upload/download API significantly, uploads from one job now must match the same artifact name on download, and `v4` artifacts cannot be downloaded by `v3` actions.
Common gotcha during migration in 2024-25. The v4 rewrite sits on a new backend and the behaviour changes bite in specific ways. Artifact names must be unique within a run, so a matrix where every cell uploads `test-results` fails on the second cell with a 409 conflict; the fix is `name: test-results-${{ matrix.os }}-${{ matrix.node }}` on upload plus `pattern: test-results-*` and `merge-multiple: true` on download.
Uploads are immutable and become visible the moment the step finishes rather than at the end of the run, so no later step can append to an existing artifact. Hidden files are excluded by default in v4, which silently drops things like `.next/` traces unless you set `include-hidden-files: true`. An empty or mistyped `path:` produces a warning and an empty artifact rather than a failure. `download-artifact@v4` can also fetch from a different workflow run using `run-id:` plus a `github-token:`, which is precisely what the safe fork-PR handshake relies on. Retention defaults to 90 days, is capped by repository or organization policy up to 400, and stored artifacts bill against repository storage, so long retention on a busy repo is a real line item.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- run: ./deploy.sh dist/
Q13When should you use `vars` instead of `secrets`, and how does an `env:` block interact with both?
BasicConfiguration
Answer
`secrets` are encrypted at rest, masked in logs, and unreadable through the API once saved. `vars` (repository, environment and organization variables, referenced as `${{ vars.NAME }}`) are plain text, visible in the settings UI and in logs, and exist so you stop hiding ordinary configuration inside secrets. Anything you would happily print, an API base URL, a region like `ap-south-1`, a feature flag, a registry namespace, belongs in `vars`. Anything whose disclosure costs money or access belongs in `secrets`. `env:` is a third thing entirely: a map of environment variables scoped to the whole workflow, one job, or one step, and it is where you usually materialise a secret or variable into the process environment.
Name resolution goes step `env` over job `env` over workflow `env`, and for both secrets and variables the storage precedence is environment level over repository level over organization level. Two limits matter in practice. Neither is available in the `on:` block, so you cannot make a cron schedule or a branch filter configurable through settings.
And `vars` are never masked, so moving a value out of `secrets` to debug it puts it permanently into the log archive of every run. Passing a secret through `env:` and then referencing `"$TOKEN"` in the shell is also safer than inlining `${{ secrets.TOKEN }}`, because the value never becomes part of the generated script text.
env:
REGION: ap-south-1 # workflow-level plain value
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # environment vars/secrets win here
env:
API_BASE: ${{ vars.API_BASE_URL }} # visible in logs, and that is fine
steps:
- name: Call the deploy API
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }} # masked, arrives as data
run: |
curl -sSf -H "Authorization: Bearer $TOKEN" \
"$API_BASE/deploy?region=$REGION"
Key Points
- `vars` for non-sensitive config, `secrets` for credentials
- Precedence: environment > repository > organization, for both
- Neither is usable inside the `on:` block
- Bind secrets via `env:` rather than inlining the expression in `run:`
Q14What is the difference between `github.ref`, `github.ref_name`, `github.head_ref`, `github.base_ref` and `github.sha`?
BasicContexts
Answer
`github.ref` is the fully qualified ref that triggered the run: `refs/heads/main` on a branch push, `refs/tags/v1.2.3` on a tag push, and `refs/pull/42/merge` on a `pull_request` event. `github.ref_name` is the same value with the prefix stripped (`main`, `v1.2.3`, `42/merge`), and `github.ref_type` is either `branch` or `tag`, which is how release workflows distinguish a tag build. `github.head_ref` is populated only on pull request events and holds the source branch name such as `feature/login`; it is empty on `push`, which is why the idiomatic concurrency group is `${{ github.head_ref || github.ref }}`. `github.base_ref` is the PR's target branch. `github.sha` is the commit the run is checked out at, and on a `pull_request` event that is the SHA of the ephemeral merge commit, not your latest commit; the branch tip is `github.event.pull_request.head.sha`. Tagging a Docker image with `github.sha` during PR CI therefore produces a tag pointing at a commit that ceases to exist after merge, which is a genuinely common bug. Other fields you reach for constantly: `github.event_name`, `github.repository` in `owner/name` form, `github.run_id` and `github.run_attempt` for building unique artifact names, `github.workspace` for the checkout path, and `github.actor`, which on a scheduled run is the last person who edited the workflow file rather than anyone who clicked something.
- name: Print the ref contexts
env:
HEAD_REF: ${{ github.head_ref }} # untrusted: branch names can hold metacharacters
BASE_REF: ${{ github.base_ref }}
run: |
echo "event = ${{ github.event_name }}"
echo "ref = ${{ github.ref }}" # refs/pull/42/merge
echo "ref_name = ${{ github.ref_name }}" # 42/merge
echo "sha = ${{ github.sha }}" # merge commit, NOT your tip
echo "head sha = ${{ github.event.pull_request.head.sha }}"
echo "branches = $HEAD_REF -> $BASE_REF"
# Tag images with the real branch tip, not the throwaway merge commit
- run: docker build -t app:${{ github.event.pull_request.head.sha || github.sha }} .
Q15How do you inspect, re-run and cancel workflow runs with the `gh` CLI?
BasicTooling
Answer
`gh run` is faster than the Actions tab for triage and it is what you reach for when a repository produces hundreds of runs a day. `gh run list --workflow=ci.yml --branch=main --status=failure --limit 20` narrows to what actually broke. `gh run view <run-id>` prints the job tree with per-job conclusions, `--log` dumps everything, and `--log-failed` prints only the steps that failed, which turns a 40 MB log into twenty readable lines and is the single most useful flag in the tool. `gh run rerun <run-id>` re-runs the whole thing, `--failed` re-runs only the failed jobs so you are billed for those alone, and `--debug` re-runs with step debug logging switched on without you having to add the `ACTIONS_STEP_DEBUG` secret to the repository. `gh run cancel` stops a run, `gh run watch` tails it live and exits non-zero if it fails, which makes it usable in a script, and `gh run download <run-id> -n dist` pulls artifacts to your machine. On the trigger side, `gh workflow run deploy.yml --ref main -f environment=production` fires a `workflow_dispatch` with inputs, and `gh workflow disable` stops a noisy scheduled workflow without deleting the file. All of it is the REST API underneath, so `gh api repos/{owner}/{repo}/actions/runs --paginate --jq ...` covers anything the porcelain commands miss.
# What failed on main recently?
gh run list --workflow=ci.yml --branch=main --status=failure --limit 10
# Only the failing step logs, not the whole 40 MB archive
gh run view 12345678 --log-failed
# Re-run just the failed jobs, with verbose runner logging turned on
gh run rerun 12345678 --failed --debug
# Fire a manual deploy and block until it finishes (non-zero exit on failure)
gh workflow run deploy.yml --ref main -f environment=production
gh run watch "$(gh run list --workflow=deploy.yml --limit 1 \
--json databaseId --jq '.[0].databaseId')"
# Anything the porcelain misses is one API call away
gh api repos/{owner}/{repo}/actions/runs/12345678/timing --jq '.billable'
Q16How do you authenticate to AWS from GitHub Actions without long-lived keys?
IntermediateCloud Auth
Answer
Use OpenID Connect (OIDC) federation. Instead of storing an AWS access key + secret in GitHub Secrets (a recurring source of leaked-credential incidents, since the key outlives every job that used it), you configure AWS IAM to trust GitHub's OIDC issuer and assume a role at runtime. Setup: (1) Create an IAM OIDC identity provider in AWS for `token.actions.githubusercontent.com`, (2) Create an IAM role with a trust policy that allows the GitHub OIDC provider to assume it, scoped to specific repos and branches via the `sub:` condition, (3) In the workflow, use `aws-actions/configure-aws-credentials@v4` with the role ARN.
Each job gets a fresh, short-lived (max 1 hour) STS session token. No long-lived keys means no key to rotate, no key to accidentally commit, no key to leak via a compromised dependency. OIDC federation is now the expected pattern at engineering-heavy companies, and storing a static AWS key pair in repository secrets reads as a finding in most cloud security reviews.
The detail interviewers dig into is the trust policy itself. The `sub` claim looks like `repo:org/repo:ref:refs/heads/main`, or `repo:org/repo:environment:production` when the job declares an environment, or `repo:org/repo:pull_request` for PR runs. A condition written as `StringLike` on `repo:org/*:*` lets any branch of any repository in the organization assume the role, which is the standard misconfiguration and effectively hands production to whoever can open a branch.
Use `StringEquals` on an exact `sub` where you can, keep `StringLike` patterns narrow, and always pin `aud` to `sts.amazonaws.com`. The other frequent failure is a missing `permissions: id-token: write`, which surfaces as `Credentials could not be loaded` or a 400 from the token endpoint, because the OIDC request URL and bearer token are only injected into the job environment when that permission is granted; a reusable workflow does not get it unless the caller grants it. Session duration is capped by the role's `MaxSessionDuration`, so long deploys need that raised rather than a retry loop. Since 2024 AWS no longer requires you to maintain the provider thumbprint, so stale-thumbprint failures are a historical artefact of older runbooks.
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: ap-south-1
- run: aws s3 sync ./dist s3://my-bucket/
Key Points
- No long-lived AWS keys in GitHub Secrets
- Trust policy scopes by repo + branch via `sub:` condition
- Requires `permissions: id-token: write` on the job
- Same pattern works for GCP (Workload Identity Federation) and Azure (Federated Credentials)
Q17What is a matrix build and when should you use it?
IntermediateMatrix
Answer
A matrix lets you run the same job across multiple parameter combinations in parallel. Most common use: testing across Node/Python/Java versions, or across operating systems. The cartesian product of all matrix dimensions becomes the job count, capped at 256 by GitHub.
Use `include:` to add specific extra combinations (e.g. test on Node 18 only on Windows), `exclude:` to skip specific combinations, and `fail-fast: false` to keep going when one cell fails, critical for library maintainers who want to see ALL failures, not just the first. The mechanics worth knowing: an `include:` entry that matches an existing combination adds keys to that cell, while one that matches nothing creates a brand-new cell, so a single `include:` block can both enrich and extend the grid. `exclude:` is applied before `include:`, which is how you carve a hole and then patch one specific case back in. Matrix values can be any JSON type, and `matrix:` itself accepts an expression, so `matrix: ${{ fromJSON(needs.discover.outputs.grid) }}` builds the combinations at runtime. `max-parallel:` throttles how many cells run at once, which matters when the tests share a database or a rate-limited third-party API.
With the default `fail-fast: true`, siblings are cancelled the instant one cell fails and they report the `cancelled` conclusion rather than `failure`, so a downstream check written against `needs.<job>.result == 'failure'` misses them. Two more surprises: only the last completed cell's values survive in `jobs.<id>.outputs`, so per-cell results must be collected as uniquely named artifacts, and branch protection matches required checks by job name, so adding a matrix dimension renames every check and can leave the old required check pending forever.
jobs:
test:
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- node: 22
os: ubuntu-latest
experimental: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}' }
- run: npm test
Q18How do you cache dependencies to speed up workflows?
IntermediateCaching
Answer
Two approaches: (1) `actions/cache@v4`, generic key/path caching. Define a cache key (usually a hash of your lock file) and the path to cache (e.g. `~/.npm`, `node_modules`, `~/.cargo`). On a cache hit the path is restored; on miss the job runs normally and uploads the cache at the end. (2) Built-in caching in the setup actions, `actions/setup-node` with `cache: 'npm'` does it automatically.
Always prefer the built-in if available because it handles edge cases (e.g. yarn berry, pnpm) correctly. Cache scope is the branch, a cache built on `feature/foo` is available to that branch and its base branch, not to unrelated feature branches. Watch the 10 GB per-repo limit; the oldest entries are evicted automatically. `restore-keys` is the part people misuse. `key` is an exact match, and if it misses, each prefix in `restore-keys` is tried in order, restoring the most recently created cache whose key *starts with* that prefix; the job then still saves a new entry under the exact `key`.
Omit `restore-keys` entirely and every lockfile change gives you a cold cache, but make the prefix too loose and you restore a stale tree that no longer matches the lockfile. Never put the full lockfile hash into a restore key, that defeats the fallback. Caches are immutable, so re-running with the same key does not refresh the entry; invalidating means versioning the key with a manual prefix like `v3-`.
An entry unused for 7 days is evicted, and when the repository crosses 10 GB the least recently used entries go first. Scope is the subtle part: a cache written on a branch is visible to that branch and branches created from it, while a cache written on the default branch is visible to every branch, which is why base-branch warming works and also why cache poisoning is possible. Prefer caching the package manager download directory (`~/.npm`, `~/.m2/repository`) over an installed `node_modules`, so `npm ci` still revalidates against the lockfile.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
# Manual alternative with actions/cache:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
Q19What is a reusable workflow and how does it differ from a composite action?
IntermediateReusability
Answer
A **reusable workflow** is a full workflow file with its own `jobs:`, called from another workflow via `uses: owner/repo/.github/workflows/file.yml@ref`. It runs on its own runners and inherits NONE of the calling context, you pass `inputs:` and `secrets:`. Use this for org-wide standardized pipelines (build → test → publish, security scan, deploy gates).
A **composite action** lives in a directory with an `action.yml` file and is a collection of steps that runs INSIDE the calling job, shares the runner, the filesystem, the env. Use this for granular reuse (a 5-step deploy snippet, a slack-notify pattern). Rule of thumb: composite action when the unit of reuse is a few steps, reusable workflow when it is a full pipeline.
Both can be versioned via git refs/tags. In practice the choice is decided by capability, not taste. A reusable workflow can fan out into its own matrix, pick different runners per job, and declare `environment:` with required reviewers, so an approval gate can live inside it; a composite action can do none of that because it is just steps grafted into your job.
A composite action, on the other hand, can be invoked mid-job, sees the caller's files, `env` and previously set step outputs, and can therefore act on state that a reusable workflow would never receive. Nesting is capped at four levels for reusable workflows and ten for composite actions. A called workflow's own `on:` triggers other than `workflow_call` are ignored, and its jobs appear in the caller's run graph prefixed with the caller job name, which is what breaks required status checks when teams switch a job to a reusable workflow.
Secrets do not flow automatically: list them under `secrets:` or use `secrets: inherit`, and note that `inherit` passes every secret the caller has, which is the wrong default when the callee belongs to another team. Composite actions have their own set of traps, covered in the question on writing one.
# Reusable workflow: .github/workflows/reusable-deploy.yml
on:
workflow_call:
inputs:
environment: { required: true, type: string }
secrets:
DEPLOY_KEY: { required: true }
jobs:
deploy:
runs-on: ubuntu-latest
steps: [...]
# Caller workflow:
jobs:
prod-deploy:
uses: ./.github/workflows/reusable-deploy.yml
with: { environment: production }
secrets:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
Q20What are the three types of custom GitHub Actions you can write?
IntermediateCustom Actions
Answer
(1) **JavaScript actions**, fastest to start, run directly on the runner with no container overhead. Action code is bundled (`@vercel/ncc`) into a single file and committed to the repo. Use this for anything that uses the GitHub API or needs cross-platform support. (2) **Docker container actions**, your action runs inside a Docker container.
Slower (image pull on every run) but lets you use any language/binary. Linux-only. Use when JS isn't practical (Python ML tooling, Go binaries, custom toolchains). (3) **Composite actions**, a YAML-only action that bundles a sequence of steps.
No code, just a recipe. Use for sharing multi-step patterns across repos without writing JS or Docker. Most modern open-source actions in 2026 are JavaScript (`actions/checkout`, `aws-actions/configure-aws-credentials`); composite is the most common for internal org-level reuse.
All three are described by an `action.yml` at the action's root containing `name`, `description`, `inputs`, `outputs` and a `runs:` block. JavaScript actions declare `using: 'node20'` (the Node 16 runtime was retired during 2024-25 and pinned workflows now emit deprecation annotations), and the entry file must be committed build output because GitHub never runs `npm install` for you, which is why `dist/` is checked in and CI usually verifies it matches the source. Inputs arrive as `INPUT_<NAME>` environment variables, and the transform is narrower than most people assume: `@actions/core.getInput()` reads `process.env['INPUT_' + name.replace(/ /g, '_').toUpperCase()]`, so only spaces become underscores while dashes are preserved and `my-input` is read from `INPUT_MY-INPUT`.
That name is not a legal shell identifier, so you reach it with `process.env` in JavaScript rather than `$INPUT_MY-INPUT` in bash. Docker actions build or pull on every single run, adding roughly 30 to 90 seconds, they only work on Linux runners, and they execute as root, so files they create can break later steps with permission errors. Composite actions cannot use `uses: docker://`, and none of the three receive repository secrets unless you pass them explicitly as inputs.
# JavaScript: .github/actions/notify/action.yml
name: Notify
description: Post a build result to Slack
inputs:
message: { required: true }
runs:
using: node20 # node16 runtime was retired in 2024-25
main: dist/index.js # BUILT bundle must be committed
# Docker: Linux only, runs as root, pulls on every run
runs:
using: docker
image: docker://ghcr.io/aquasecurity/trivy:0.58.0
args: ['fs', '--exit-code', '1', '.']
# Composite: shell is mandatory on every run step
runs:
using: composite
steps:
- shell: bash
env:
MESSAGE: ${{ inputs.message }}
run: echo "$MESSAGE"
Q21How do you handle workflow concurrency to avoid race conditions?
IntermediateConcurrency
Answer
Use the `concurrency:` key. It groups runs by a key you define and serializes them: when a new run starts in a group, the previous one is either queued or cancelled. Two essential patterns: (1) **Auto-cancel old PR runs**, set `group: pr-${{ github.head_ref }}` with `cancel-in-progress: true` so when you push a new commit, the running CI on the previous commit cancels.
Saves a huge amount of minutes. (2) **Serialize deploys**, `group: deploy-prod` with `cancel-in-progress: false` to prevent two deploys racing into production at the same time. Without this, a fast-following second merge can deploy before the first is finished, and you get partial overwrites. The details that matter in production: the group is any string you compose, and the idiomatic default is `${{ github.workflow }}-${{ github.ref }}` so pushes to different branches never cancel each other.
Use `${{ github.head_ref || github.ref }}` when one workflow handles both `push` and `pull_request`, otherwise the PR run and the branch push land in different groups and you pay for both. Only one run can sit pending per group: queue a third run behind a running one and the older pending run is cancelled outright, so `cancel-in-progress: false` gives you at most one waiting run, not a FIFO buffer, which is why deploy queues that must not drop anything are built with an external lock or a merge queue rather than this key. Concurrency can also be declared at job level, which is how you serialise only the deploy job while tests still run in parallel.
Cancelled runs report the `cancelled` conclusion, so a Slack notifier keyed on `failure()` silently misses them while `always()` catches too much; `if: ${{ !cancelled() }}` is usually what you want. A cancelled job gets a short grace period to finish cleanup steps before the runner is killed.
# Cancel previous PR runs
name: CI
on: pull_request
concurrency:
group: pr-${{ github.head_ref }}
cancel-in-progress: true
---
# Serialize prod deploys (no cancel, queue them)
name: Deploy
on:
push: { branches: [main] }
concurrency:
group: deploy-production
cancel-in-progress: false
Q22What are GitHub Environments and why use them?
IntermediateEnvironments
Answer
Environments are named deployment targets (typically `staging`, `production`) with their own scoped secrets, variables, and protection rules. Three killer features: (1) **Required reviewers**, a job referencing `environment: production` cannot run until N specific users approve. This is the standard way to gate prod deploys in 2026. (2) **Deployment branches**, restrict which branches are allowed to deploy to this environment (e.g. `main` only). (3) **Wait timer**, force a delay before deployment so you have a window to abort.
Environment secrets override repo-level secrets, so `secrets.AWS_ROLE` automatically resolves to the production role when the job runs in the production environment. Audit log is per-environment, which is what auditors (especially for fintech RBI compliance) want to see. Operational details worth naming: a job waiting on required reviewers holds no runner and burns no minutes while it waits, but it still counts against the 35-day workflow limit and the approval request expires after 30 days.
With `prevent self-review` enabled the person who triggered the run cannot approve their own deployment, which is the specific control auditors ask about. Environment secrets resolve only inside a job that declares `environment:`, so a shared setup job cannot read them. Deployment branch policies can be expressed as branch name patterns or tag patterns, so a release-only environment can require `v*` tags.
The `url:` field renders a clickable link in the PR timeline and the Deployments panel, and GitHub records a real deployment object, which keeps the Deployments API and `deployment_status` workflows working. Reusable workflows can declare `environment:` themselves, which is how platform teams centralise an approval gate. The classic gotcha: naming an environment that does not exist creates it silently with no protection rules attached, so a typo in the name quietly bypasses your approval requirement.
jobs:
deploy:
environment:
name: production
url: https://goodspace.ai
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE }} # production-scoped secret
- run: ./deploy.sh
Q23How does the security model handle PRs from forks?
IntermediateSecurity
Answer
By default, `pull_request` workflows from forks run with a read-only `GITHUB_TOKEN` and zero access to repo secrets. This is the central security boundary: without it, anyone could open a PR with a malicious workflow that exfiltrates your production keys. The downside: legitimate fork-PR workflows (running tests against the PR's code) cannot deploy preview environments or comment on the PR with results.
The escape hatch is the `pull_request_target` trigger, it runs in the base repo's context (with secrets) but checks out the base ref by default. CRITICAL: if you use `pull_request_target` and check out the PR head (`ref: ${{ github.event.pull_request.head.sha }}`), you are running untrusted code with full secrets, this is the #1 RCE pattern in GitHub Actions, exploited many times in 2022-23. Mitigation: never check out PR head code in `pull_request_target`, OR gate it behind a manual approval / org-membership check / `safe-to-test` label.
The safest pattern in 2026 for fork-PR preview environments is the two-workflow handshake: a `pull_request` workflow runs tests on the untrusted code with no secrets and uploads results as an artifact; a separate `workflow_run` workflow (triggered when the first completes) downloads the artifact in the trusted context and posts a PR comment or deploys a preview. This isolates untrusted code execution from the secret-bearing context completely.
Key Points
- `pull_request` from fork = read-only token, no secrets
- `pull_request_target` = base repo context with secrets, DANGEROUS if you check out PR code
- Standard pattern: run untrusted code in `pull_request`, post results from `pull_request_target` triggered by `workflow_run`
Q24What is `permissions:` and why should you set it explicitly?
IntermediateSecurity
Answer
`permissions:` controls the scope of `GITHUB_TOKEN` for a workflow or job. By default (when not set), GitHub gives the token write access to most scopes, fine for trusted internal repos, but excessive for open-source. The safer pattern in 2026: set `permissions: read-all` at the workflow level, then grant specific writes per-job (e.g. `contents: write` for a release job, `pull-requests: write` for a label bot, `id-token: write` for OIDC).
This is the principle of least privilege applied to CI. It also dramatically limits the blast radius of a compromised third-party Action: even if `evilaction@v1` tries to push to your repo, it cannot if the job only has `contents: read`. Precision matters here because `permissions:` is not additive: declaring even one scope sets every other scope to `none`, so a job that adds `id-token: write` and forgets `contents: read` fails at `actions/checkout` with a 403 on fetch.
A job-level block fully replaces the workflow-level block rather than merging with it. The shorthands are `read-all`, `write-all` and `{}`, the last meaning deny everything, which is right for a job that only runs shell. Scopes map to API surfaces: `contents`, `pull-requests`, `issues`, `packages`, `deployments`, `actions`, `checks`, `attestations`, `id-token`, and `security-events`, which is the one CodeQL or Trivy needs to upload SARIF.
You can never grant more than the token already has, so an organization setting capping the default at read-only means a workflow asking for `contents: write` silently gets read, and fork PRs get read-only regardless of the YAML. Reusable workflows inherit the caller's permissions and can only narrow them further, never widen.
permissions: read-all
jobs:
release:
permissions:
contents: write # to tag/release
id-token: write # for OIDC to npm or AWS
runs-on: ubuntu-latest
steps: [...]
Q25What are self-hosted runners and when do you need them?
IntermediateRunners
Answer
Self-hosted runners are machines (VMs, containers, or bare metal) you manage that connect back to GitHub and accept jobs. You need them when: (1) you need specific hardware (GPUs for ML training, ARM for embedded), (2) the build has high RAM/CPU needs that exceed GitHub-hosted (e.g. 64+ GB monorepos), (3) compliance demands code stay inside your VPC (most fintech and healthcare in India), (4) cost, at >10k minutes/month a fleet of self-hosted runners on cheap spot instances is dramatically cheaper than GitHub-hosted. In 2026 the dominant pattern is **runners on Kubernetes** via the official `actions/actions-runner-controller` (ARC).
It auto-scales runner pods to match queue depth, kills idle pods, and integrates with AWS EKS / GKE for cheap compute. Critical security rule: NEVER use self-hosted runners on a public repo without a labeled-runner gate, anyone can open a PR and execute arbitrary code on your VPC machine. Operationally, two rules keep a fleet safe.
Register runners with `--ephemeral` so each accepts exactly one job then deregisters, which stops a job from leaving behind a poisoned npm cache, a modified `~/.gitconfig`, or a background process for the next tenant; ARC does this by default. And treat labels as routing, not authorisation: `runs-on: [self-hosted, prod]` only selects a pool, and anyone who can add a workflow file can target it, so real isolation has to come from the runner's own IAM role, network policy and namespace. Budget for the boring failures too.
A runner that loses its connection shows up as a job queued forever with no error message and no timeout until `timeout-minutes` fires. The runner binary auto-updates and can break against a pinned container image unless you pass `--disableupdate` and manage upgrades yourself. And the `_work` directory is never cleaned on persistent runners, so disks fill until builds fail with `no space left on device`, which is another argument for ephemeral pods.
Key Points
- Use cases: special hardware, VPC compliance, cost at scale
- 2026 dominant pattern: ARC on Kubernetes (EKS/GKE)
- Public repos + self-hosted = RCE risk; use ephemeral runners + label gating
Q26How do you trigger a workflow from another workflow?
IntermediateTriggers
Answer
Four ways: (1) `workflow_run`, fires after another workflow completes. Useful for 'post-CI cleanup' or 'on-deploy notification' patterns. (2) `repository_dispatch`, fires when you POST to the GitHub API with a custom event_type. Used for external systems triggering CI (e.g. a CMS publish triggering a static site rebuild). (3) `workflow_dispatch` via GitHub API, programmatically trigger a manual workflow with inputs. (4) `workflow_call`, for reusable workflows.
The trap: actions using the default `GITHUB_TOKEN` cannot trigger another workflow, by design, to prevent loops. If you need workflow A to push a commit that triggers workflow B, use a PAT or GitHub App. The sharp edges are concentrated in `workflow_run`.
It only fires for workflow files that exist on the default branch, and the triggered run always executes the default branch's version of its own YAML with the default branch's context, so `github.ref` is `refs/heads/main` even when the upstream run happened on a PR branch. That is deliberate: it is exactly what makes `workflow_run` a trusted place to handle output produced by untrusted fork code. To reach the upstream run's artifacts you read `github.event.workflow_run.id` and pass it to `actions/download-artifact@v4` as `run-id:` along with a token. The `types: [completed]` event fires for success, failure and cancellation alike, so you must test `github.event.workflow_run.conclusion` yourself or you will post green Slack messages about failed builds. `repository_dispatch` needs a POST to `/repos/{owner}/{repo}/dispatches` with an `event_type` and a `client_payload` limited to 10 top-level properties, and it too only runs the default branch's workflow. `gh workflow run` is the quickest way to test a `workflow_dispatch` with inputs.
# Workflow B triggers AFTER Workflow A finishes
name: Post-CI
on:
workflow_run:
workflows: ['CI']
types: [completed]
branches: [main]
jobs:
notify:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- run: curl -X POST $SLACK_WEBHOOK -d 'CI passed'
Q27How do you pin third-party Actions for security?
IntermediateSecurity
Answer
Three options, in increasing security: (1) `uses: org/action@v4`, pins to a major version tag. Convenient but the tag can be moved by the action's maintainer (or attacker who compromises the repo). (2) `uses: org/action@v4.1.0`, pins to a specific release tag. Same caveat, tags are mutable. (3) `uses: org/action@a1b2c3d4...`, pins to a full commit SHA.
Immutable, the gold standard for security-sensitive workflows. The `tj-actions/changed-files` compromise reinforced that SHA-pinning is the only protection against a maintainer-account compromise propagating supply-chain attacks. Use `dependabot` configured for `package-ecosystem: github-actions` to automatically open PRs that bump SHA-pinned dependencies, you get the security AND the automated updates.
Two practical additions. Dependabot only renders a readable version in the PR when you keep the human version in a trailing comment (`# v4.2.2`), so that comment is load-bearing, not decoration. And pinning the top-level action is necessary but not sufficient: a composite action you pinned can itself call `uses: some/other@main`, and a JavaScript action ships a bundled `dist/` nobody audits, so enforcement belongs at the org level via an allowlist of permitted actions plus the repository ruleset option requiring full-length commit SHAs.
GitHub added immutable releases and action attestations during 2025, so for publishers who opt in you can verify provenance rather than trusting a tag at all. The reason SHA pinning is the answer an interviewer wants: in the March 2025 `tj-actions/changed-files` compromise the attacker rewrote existing version tags to point at a malicious commit that dumped runner process memory, and therefore secrets, into public build logs. Every repository that pinned by SHA kept executing the old clean commit and was unaffected.
# Insecure (tag can be moved)
- uses: tj-actions/changed-files@v44
# Secure (SHA-pinned)
- uses: tj-actions/changed-files@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 # v44.5.1
Q28How would you set up a multi-stage CI/CD pipeline (lint → test → build → deploy)?
IntermediatePipelines
Answer
Define four jobs with `needs:` chains. Lint and unit tests can run in parallel (independent), build needs both to pass, deploy needs build. Use `if:` to skip deploy on non-main branches.
Add `concurrency:` to cancel old PR runs. Add `permissions:` for least privilege. Use environments with required reviewers for the deploy.
This shape, with minor variations, is the baseline most product teams converge on. The design decisions an interviewer will actually probe: keep the gate jobs cheap and fail fast, so lint and type-check finish in under a minute on one runner while anything slow (end-to-end tests, container builds) hides behind `if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'run-e2e')`. Build once and promote the same artifact through environments instead of rebuilding per environment, otherwise staging and production ship different bytes and a staging pass proves nothing.
Make the deploy job the only one holding write permissions and the only one with an `environment:`, so approvals, secrets and audit records all live in exactly one place. Give every job a `timeout-minutes:` because the default is 360 and one hung test otherwise burns six hours of billed minutes. Use `needs:` for ordering, but remember a skipped upstream job skips everything downstream, so an optional job wants `continue-on-error: true` rather than an `if:`. And name jobs stably: branch protection matches required checks by job name, so renaming `test` to `unit-test` leaves the old required check pending forever and blocks every merge.
name: CI/CD
on:
push: { branches: [main] }
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
jobs:
lint:
runs-on: ubuntu-latest
steps: [..., { run: npm run lint }]
test:
runs-on: ubuntu-latest
steps: [..., { run: npm test }]
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps: [..., { run: npm run build }, { uses: actions/upload-artifact@v4, with: { name: dist, path: dist/ } }]
deploy:
needs: build
if: github.ref == 'refs/heads/main'
environment: production
permissions: { id-token: write, contents: read }
runs-on: ubuntu-latest
steps: [..., { run: ./deploy.sh }]
Q29How do you prevent script injection when a workflow interpolates `${{ github.event.pull_request.title }}` into a `run:` block?
IntermediateSecurity
Answer
`${{ }}` expressions are substituted into the generated shell script before the shell ever sees it, so an attacker-controlled value becomes code, not data. A pull request titled with a quote followed by a semicolon and a curl-pipe-shell payload executes on your runner with that job's token and whatever secrets it holds. The dangerous inputs are anything a non-collaborator can set: `github.event.issue.title` and `.body`, `pull_request.title` and `.body`, `comment.body`, `review.body`, `head_ref` (branch names allow far more characters than people assume), `head.repo.description`, and commit message or author fields.
The fix is never to interpolate untrusted data into a script. Bind it to an environment variable with `env:` and reference the quoted shell variable instead; the value travels through the process environment and is never parsed as code. Inside `actions/github-script`, read `process.env.TITLE` rather than an inline expression.
For anything more involved, move the logic into a checked-in script or a JavaScript action that takes the value as a declared input. Two supporting defences: give the job a minimal `permissions:` block so a successful injection cannot push code or open a release, and keep untrusted checkouts out of `pull_request_target`. The follow-up question is usually why quoting the expression is not enough, and the answer is that YAML quoting happens first and substitution happens afterwards, so any quote character in the payload closes your string.
# WRONG: the title is pasted into the script before bash runs
- run: echo "PR title: ${{ github.event.pull_request.title }}"
# RIGHT: the title arrives as data through the environment
- name: Enforce a conventional-commit title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "PR title: $PR_TITLE"
grep -Eq '^(feat|fix|chore|docs|refactor)(!)?: ' <<< "$PR_TITLE" || {
echo "::error::Title must start with a conventional-commit prefix"
exit 1
}
Key Points
- Expressions are textual substitution into the script, not variable binding
- Untrusted: titles, bodies, comments, branch names, commit metadata
- Bind through `env:` and quote the shell variable
- Pair with least-privilege `permissions:` to cap the blast radius
Q30How do you authenticate to Google Cloud from GitHub Actions using Workload Identity Federation?
IntermediateCloud Auth
Answer
Same OIDC mechanism as AWS, different plumbing. You create a Workload Identity Pool and, inside it, an OIDC provider whose issuer URI is `https://token.actions.githubusercontent.com`, map claims (`google.subject` from `assertion.sub`, plus attributes such as `attribute.repository` from `assertion.repository`), and then either bind that federated principal directly to IAM roles on the target resources (direct Workload Identity Federation, the current recommendation) or grant it `roles/iam.workloadIdentityUser` on a service account and impersonate it. In the workflow you add `permissions: id-token: write` and call `google-github-actions/auth@v2` with `workload_identity_provider:` and, when impersonating, `service_account:`.
The action writes a credential configuration file and exports `GOOGLE_APPLICATION_CREDENTIALS`, so `gcloud`, `gsutil` and every Google client library pick it up with no key JSON anywhere on disk. The mandatory hardening step is an attribute condition on the provider such as `assertion.repository == 'goodspaceai/webapp'`; without it any GitHub repository on the internet can mint tokens against your pool, and that misconfiguration has produced real breaches. Typical failures read `Unable to acquire impersonated credentials`, which means the service account is missing the `workloadIdentityUser` binding, or a rejection by the attribute condition, which means your `sub` does not match. The pattern this replaces is a service account key JSON pasted into a repository secret, and arguing against that is usually the point of the question.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/gh-oidc
service_account: deployer@my-project.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: |
gcloud run deploy api \
--image=asia-south1-docker.pkg.dev/my-project/apps/api:${{ github.sha }} \
--region=asia-south1
Q31How do you run integration tests against a real Postgres or Redis using `services:`?
IntermediateTesting
Answer
Declare them under the job's `services:` key. Each entry starts a Docker container on the same user-defined bridge network as the job. When the job runs directly on the VM, which is the usual case, you reach the service on `localhost` through whatever you publish with `ports:`.
When the job itself runs inside a `container:`, you instead address the service by its label as a hostname (`postgres:5432`) and you do not need `ports:` at all. That asymmetry is the single biggest source of `ECONNREFUSED` in integration jobs, because the same YAML behaves differently depending on whether `container:` is present. Always attach a healthcheck through `options: --health-cmd ... --health-interval ... --health-retries ...`.
Without one the runner starts your steps as soon as the container is created, and Postgres reliably refuses the first few connections while it initialises, producing a suite that passes locally and fails only in CI. Writing `ports: ['5432:5432']` fixes the host port; publishing only the container port assigns a random host port you read from `job.services.postgres.ports['5432']`. Service containers are Linux-only, they are torn down with the job, and they cannot be shared between jobs. When you need a stack you also build yourself, `docker compose up -d --wait` inside a `run:` step is usually simpler than bending the `services:` syntax around it.
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports: ['6379:6379']
options: --health-cmd "redis-cli ping" --health-interval 10s --health-retries 5
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
REDIS_URL: redis://localhost:6379
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test:integration
Q32How do you build a matrix dynamically from a previous job's output with `fromJSON`?
IntermediateMatrix
Answer
`strategy.matrix` accepts an expression, so the grid can be computed at runtime instead of hard-coded. The pattern is two jobs: a `discover` job that emits a JSON array as a job output, and a build job with `needs: discover` and `matrix: ${{ fromJSON(needs.discover.outputs.targets) }}`. The output has to be a single-line valid JSON string, either an array of scalars, which becomes the values of one matrix key, or an array of objects, where each object becomes one cell with those keys, which is what you feed to `include:`. `jq -c` is how you compact it; a stray newline makes the whole workflow fail at parse time with an expression error and no job logs to inspect, because matrix expansion happens before any job is created.
Typical uses are building only the monorepo packages that changed, fanning out one job per Terraform stack directory, or generating a version matrix from a support-policy file so dropping Node 18 is a one-line data change. Guard the empty case: an empty array produces zero jobs, and a downstream `needs:` on that job then reports `skipped`, which fails a required status check unless an aggregate gate job absorbs it. Remember the 256-job ceiling per matrix and use `max-parallel:` when the cells contend for a shared resource.
jobs:
discover:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.set.outputs.targets }}
steps:
- uses: actions/checkout@v4
- id: set
run: |
# one object per service directory, compacted onto a single line
TARGETS=$(ls -d services/*/ | xargs -n1 basename \
| jq -R -c '{service: .}' | jq -s -c .)
echo "targets=$TARGETS" >> $GITHUB_OUTPUT
build:
needs: discover
if: needs.discover.outputs.targets != '[]'
strategy:
max-parallel: 5
matrix:
include: ${{ fromJSON(needs.discover.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make -C services/${{ matrix.service }} build
Q33How do you write a composite action with inputs, outputs and `github.action_path`?
IntermediateCustom Actions
Answer
A composite action is a directory containing an `action.yml` with `runs: using: 'composite'` and a `steps:` list, invoked with `uses:` and executed inside the calling job, sharing its runner, filesystem and environment. The rules that differ from a workflow catch everyone at least once. Every `run:` step must declare `shell:` explicitly because there is no default.
Inputs are read as `${{ inputs.name }}` and are always strings even when you declare a type, so a boolean input arrives as the text `true` and `if: inputs.flag` is true for the string `false` as well. Outputs are declared at the top level with `value: ${{ steps.<id>.outputs.<name> }}`, mapping an inner step's output outward; forget that mapping and the caller silently reads an empty string. The `secrets` context does not exist inside a composite action at all, so credentials must be passed as inputs, and they stay masked because masking is value-based rather than name-based.
Files shipped alongside the action must be referenced through `$GITHUB_ACTION_PATH`, since the working directory belongs to the caller, not the action. Composite actions can call other actions, nested up to ten deep. Local ones (`uses: ./.github/actions/x`) require `actions/checkout` first, and carry the `pull_request_target` hazard described under `uses` versus `run`.
# .github/actions/publish/action.yml
name: Publish image
description: Build, push, and return the pushed digest
inputs:
registry: { required: true }
tag: { required: false, default: latest }
outputs:
digest:
description: Image digest that was pushed
value: ${{ steps.push.outputs.digest }} # map the inner step outward
runs:
using: composite
steps:
- shell: bash
run: "$GITHUB_ACTION_PATH/scripts/preflight.sh" # not ./scripts
- id: push
shell: bash # mandatory on every composite run step
env:
REGISTRY: ${{ inputs.registry }}
TAG: ${{ inputs.tag }}
run: |
docker build -t "$REGISTRY:$TAG" .
docker push "$REGISTRY:$TAG"
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$REGISTRY:$TAG")
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
Q34How do you build and push a multi-architecture Docker image with layer caching?
IntermediateDocker
Answer
Four actions do the work: `docker/setup-buildx-action` creates a BuildKit builder, `docker/setup-qemu-action` registers binfmt handlers so a `linux/arm64` stage can execute on an x86 runner, `docker/login-action` authenticates to the registry, and `docker/build-push-action` builds with `platforms: linux/amd64,linux/arm64` and pushes a manifest list. Caching is where the wins are. `cache-from: type=gha` with `cache-to: type=gha,mode=max` stores BuildKit's layer cache in the same 10 GB Actions cache your dependencies use; `mode=max` also stores intermediate stages, which is what makes a multi-stage build genuinely fast and also what fills the quota, so scope the cache per image with `scope:`. The alternative, `type=registry,ref=...:buildcache`, keeps the cache in your registry, which is the better choice when several repositories or self-hosted runners share it, and it survives cache eviction.
QEMU emulation of an ARM build runs roughly three to ten times slower than native, so the 2026 pattern is a matrix that builds amd64 on `ubuntu-24.04` and arm64 natively on `ubuntu-24.04-arm`, pushes both by digest, then a final job stitches them with `docker buildx imagetools create`. Use `docker/metadata-action` to generate tags and OCI labels rather than hand-building tag strings, and remember `packages: write` is required to push to GHCR.
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
- uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
Q35How do you run CI only for the parts of a monorepo that changed, without breaking required status checks?
IntermediateMonorepo
Answer
There are two layers and they solve different problems. The coarse layer is workflow-level `paths:` on the `push` or `pull_request` trigger, which prevents the workflow run from being created at all. It is cheap but blunt, and it interacts badly with branch protection, because a required check that never runs sits pending forever and blocks the merge.
The fine layer is a single `changes` job using `dorny/paths-filter@v3`, which diffs against the merge base and exposes one boolean output per filter; downstream jobs then gate on `if: needs.changes.outputs.api == 'true'`. Those jobs are skipped rather than absent, so the fix for branch protection is an always-run aggregate job (commonly called `ci-required`) that inspects `needs.*.result` and fails only on a genuine failure or cancellation. You make that one job the required check and never touch the setting again.
Globs alone are not sufficient for correctness: a change to `packages/ui` must rebuild every app importing it, which is a graph question, not a path question, so `nx affected --base=origin/main`, `turbo run build --filter='...[origin/main]'` or `pnpm --filter '...[origin/main]'` compute it from real dependencies. Both layers need enough git history, so `fetch-depth: 0` or an explicit fetch of the base commit.
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api: ['apps/api/**', 'packages/shared/**']
web: ['apps/web/**', 'packages/shared/**']
api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm test -w apps/api }]
ci-required: # make THIS the single required status check
needs: [api]
if: always()
runs-on: ubuntu-latest
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
Key Points
- Workflow `paths:` filters stop the run; job-level filters skip jobs
- Skipped jobs report pending to branch protection, so add an aggregate gate job
- Globs cannot express dependencies; use nx/turbo/pnpm affected for that
- Needs `fetch-depth: 0` to diff against the merge base
Q36How do you cut GitHub Actions billing minutes on a private repository?
IntermediateCost
Answer
Measure first. The organization billing page breaks spend down per repository and per workflow, and the `/actions/runs/{id}/timing` REST endpoint returns billable milliseconds split by runner OS, so you can rank workflows by real cost instead of by how slow they feel. Then work the levers in order of payoff.
Multipliers dominate everything else: Linux bills at 1x, Windows at 2x, macOS at 10x, so one careless macOS matrix cell can outweigh every other optimisation, and `ubuntu-24.04-arm` is cheaper per minute than x86 for anything with an ARM toolchain. Next, `concurrency` with `cancel-in-progress: true` on pull requests, which stops paying for CI on commits nobody will merge. Then path filters and affected-graph selection so a README change does not run the full suite.
Then caching, `npm ci` over `npm install`, and prebuilt Docker layers. Then `timeout-minutes:` on every job, because the default of 360 turns one hung test into six hours of billing. Two structural facts matter: each job is rounded up to a whole minute, so ten trivial jobs cost ten minutes regardless of runtime, and public repositories are free on standard runners but still billed on larger runners. Finally, audit scheduled workflows, a nightly job nobody reads is pure spend.
# Where the money actually goes: billable ms per run, split by runner OS
gh api "repos/$OWNER/$REPO/actions/runs/$RUN_ID/timing" --jq '.billable'
# Cancel superseded PR runs, keep pushes to main serialised
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-24.04-arm # cheaper per minute than x86
timeout-minutes: 15 # the default is 360
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci && npm test
Q37Once affected-graph selection is working, how do you operate a monorepo's CI across many teams: remote caching, per-package releases, and cost attribution?
AdvancedArchitecture
Answer
Selecting the affected projects is the entry ticket, covered separately. What decides whether a monorepo's CI survives twenty teams is the platform around that selection. **Remote build caching** is the largest single lever: Nx Cloud, Turbo Remote Cache or a Bazel remote cache key each task by a hash of its inputs and toolchain, so an unchanged package is restored rather than rebuilt even on a runner that has never seen the repository. That only holds if the tasks are hermetic, so an undeclared input such as a stray env var or a wall-clock timestamp shows up as a mysteriously low hit rate rather than a wrong answer.
Treat hit rate as an SLO you graph. Give pull requests a read-only cache token and let only default-branch runs write, otherwise a contributor's build populates the entry that everyone else restores. **Ownership** scales by giving each package its own reusable workflow inside its directory, guarded by CODEOWNERS, with a thin orchestrator that fans out to them; the platform team owns the orchestrator and the shared actions, and no one edits a single 2,000-line file. **Releases** go independent: changesets or `nx release` version and publish only the affected packages, tags are namespaced as `@scope/pkg@1.4.0` so they never collide, and because `GITHUB_TOKEN` pushes do not trigger downstream workflows, the release job mints a GitHub App token when a publish must set off a deploy. **Cost attribution** needs the runner labels to carry the answer: run ARC scale sets per team or per workload class, so `runs-on: arc-payments-xl` maps to a Kubernetes node pool with its own budget, and reconcile with billable milliseconds from the `/actions/runs/{id}/timing` endpoint. Size the pools on queue wait time, not utilisation, because developers experience the wait and idle runners are cheaper than blocked engineers.
# Orchestrator: one literal call per package workflow (uses: takes no expressions)
jobs:
payments:
uses: ./.github/workflows/pkg-payments.yml # owned via CODEOWNERS
secrets: inherit
checkout-ui:
uses: ./.github/workflows/pkg-checkout-ui.yml
secrets: inherit
# Inside pkg-payments.yml: remote cache, write-gated to the default branch
jobs:
build:
runs-on: arc-payments-xl # ARC scale set = one team's node pool + budget
env:
NX_CLOUD_ACCESS_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.NX_RW || secrets.NX_RO }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npx nx build payments
release: # independent, per-package versioning
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1 # so publishes trigger deploys
id: app
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_KEY }}
- run: npx nx release --projects=payments
Key Points
- Remote build cache is the biggest lever; hermetic tasks make it trustworthy
- PRs read the cache, only the default branch writes it
- One reusable workflow per package, CODEOWNERS-guarded, thin orchestrator on top
- Per-package releases with namespaced tags and an App token for downstream triggers
- Per-team ARC scale sets plus the run timing API give real cost attribution
Q38SHA-pinning is a per-workflow habit. How do you enforce third-party Action supply-chain security across a whole organization?
AdvancedSecurity
Answer
Pinning is a per-workflow discipline, and discipline does not scale, so the interesting answer is what makes it unskippable. Four layers. **Policy**: the organization's Actions settings restrict runs to local actions plus an explicit allowlist of `owner/action@*` patterns, and an organization ruleset can require that every referenced action be a full-length commit SHA, which turns pinning into a push-time rejection instead of a review comment. Both apply across every repository, including ones created next week. **Provenance**: publishers who opt into immutable releases have their release tags and assets frozen after publication, which closes the tag-rewrite class of attack at the source rather than defending against it downstream.
Prefer those publishers where you have a choice, but do not assume it, because adoption across the Marketplace is partial and an action that has not opted in still has movable tags. Where an action ships a released binary you can also check its attestation before use, using the verification flow covered in the build-provenance question. **Update flow**: Dependabot with `package-ecosystem: github-actions` bumps SHA pins on a schedule, and because the diff shows one opaque hash, the review that matters is comparing the upstream commit range, not eyeballing the PR. **Vendoring**: for the small number of actions in the blast radius of a deploy credential, fork or copy them into an internal repository and consume the fork, which trades update lag for the ability to review every line that reaches a privileged runner. Underneath all of it, keep the blast radius small: default `permissions: {}` at the organization level, grant scopes per job, and put deploy credentials in environment-scoped secrets so a compromised lint action never sees them.
# .github/dependabot.yml: move the pins on a schedule, org-wide
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
groups:
actions: { patterns: ['*'] } # one reviewable PR, not twenty
---
# Org ruleset (API shape): make full-length SHAs a push-time requirement
{
"name": "require-sha-pinned-actions",
"target": "push",
"enforcement": "active",
"rules": [{ "type": "workflows" }]
}
---
# What a consuming workflow then looks like: pinned, vendored, least-privilege
permissions: {} # deny by default, grant per job
jobs:
deploy:
permissions: { id-token: write, contents: read }
environment: production # credentials scoped to the environment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# vendored fork of a privileged action, reviewed line by line
- uses: my-org/vendored-deploy@3f1a9c2e5b7d4a6c8e0f2b4d6a8c0e2f4b6d8a0c
Key Points
- Org allowlist plus a ruleset requiring full-length SHAs makes pinning enforced, not advisory
- Immutable releases freeze tags at the source, but Marketplace adoption is partial
- Dependabot for `github-actions` moves the pins; review the upstream commit range
- Vendor the few actions that run near a deploy credential
Q39How does GitHub Actions compare to CircleCI, Jenkins, and Buildkite?
AdvancedComparison
Answer
**GitHub Actions** wins on integration: zero setup if your code is on GitHub, native PR comments, deployment status, environments, OIDC, all in one platform. The Marketplace ecosystem is the largest of any CI provider. Weakness: complex pipelines (parallelism, dynamic fanout, complex caching) feel verbose vs CircleCI's DSL, and the YAML expression language is limited. **CircleCI** has stronger primitives for caching, parallelism, and orbs (their version of composite actions), and the UX for debugging is more mature, but it is a separate platform you have to wire up. **Jenkins** is the legacy heavyweight: infinite extensibility via plugins, runs on your hardware, no per-minute cost.
But the operational overhead is significant (Master upgrades, plugin compatibility hell, JVM tuning), most companies migrating off Jenkins in 2023-26 go to GitHub Actions. **Buildkite** is the high-end choice: agents run on YOUR infrastructure, control plane is hosted. It's used at scale by companies like Shopify and Wayfair where the build is the bottleneck of engineering productivity, but it's overkill for most. For Indian startups in 2026, GitHub Actions is the default unless you already have substantial Jenkins infrastructure or need Buildkite-level control over agent scheduling.
Q40How do you implement progressive deployment (canary / blue-green) with GitHub Actions?
AdvancedDeployment
Answer
GitHub Actions itself doesn't ship a 'deploy 5% then 50% then 100%' primitive, you compose it from environments, manual approvals, and your platform's traffic-shifting APIs. Architecture for a typical canary deploy: (1) Build job produces a versioned artifact / Docker image, (2) `canary` environment job deploys to a small subset (5% of traffic via ALB weighted target groups, or 1-pod canary in Kubernetes), (3) Automated check job polls metrics from Prometheus/Datadog/SignOz for 5-10 minutes; fails the workflow if error rate or latency p99 exceeds thresholds, (4) `production` environment job (with required reviewer approval) shifts traffic to 100% and marks the rollout complete, (5) On failure or manual abort, a rollback job re-points traffic to the previous version. The traffic shifting itself is done by your platform's APIs (kubectl, AWS CLI, Argo Rollouts), Actions is just the orchestration layer.
Most Indian startups use Argo Rollouts on EKS for the data plane and GitHub Actions for the control plane, with SignOz or Datadog for the canary verification step. Two points worth saying out loud in an interview. The deployment record should be a real GitHub Deployment, created through the `environment:` key or the Deployments API, so the PR timeline, the Deployments panel and any rollback tooling can see current state rather than inferring it from workflow logs. And the verification job needs both a hard `timeout-minutes:` and an explicit failure path, because a metrics query that hangs otherwise leaves the canary serving live traffic indefinitely while the workflow sits idle and nobody is paged.
jobs:
canary:
environment: canary
runs-on: ubuntu-latest
steps:
- run: kubectl argo rollouts set image api api=$IMAGE
verify:
needs: canary
runs-on: ubuntu-latest
timeout-minutes: 15 # never let a hung query strand the canary
steps:
- name: Watch the canary error rate for 10 minutes
run: |
for _ in $(seq 1 20); do
RATE=$(./scripts/canary-5xx-rate.sh) # prints a float
if awk -v r="$RATE" 'BEGIN{exit !(r > 0.01)}'; then
echo "::error::canary 5xx rate $RATE above threshold"; exit 1
fi
sleep 30
done
promote:
needs: verify
environment: production # required reviewers sit here
runs-on: ubuntu-latest
steps:
- run: kubectl argo rollouts promote api --full
rollback:
needs: [canary, verify]
if: failure()
runs-on: ubuntu-latest
steps:
- run: kubectl argo rollouts abort api
Q41How do you debug a flaky GitHub Actions workflow that fails intermittently?
AdvancedDebugging
Answer
Flaky workflows are usually one of five things: (1) **Network flakiness**, npm/Docker Hub timeouts. Mitigation: retry the step with `nick-fields/retry@v3` (set `max_attempts: 3`), use a package manager mirror, pin Docker images by digest. (2) **Race conditions in tests**, port conflicts, file system races, time-of-day-dependent tests. Re-run the suite locally with `--runInBand` (Jest) or `-n 0` (pytest-xdist) to see if parallelism is the cause. (3) **Cache poisoning**, a corrupted cache that survives across runs.
Workaround: bump the cache key with a salt, or use `cache-dependency-path` with a hash of more files. (4) **Runner resource exhaustion**, OOM killer on small runners. Move to a larger runner or a self-hosted one. (5) **Time-based assertions**, tests that depend on `Date.now()` or timezones. Always set `TZ=UTC` in the workflow env.
Debugging tools: enable `ACTIONS_STEP_DEBUG=true` (set as a repo secret) to get verbose logs, use `mxschmitt/action-tmate@v3` to SSH into a failed runner for live inspection, download the full log zip via the API for grep-friendly analysis. For chronic flakes, instrument every step with timing and structured logging, most flakes have a signature in the logs once you know what to look for.
Key Points
- Retries via `nick-fields/retry@v3` for transient network failures
- Pin Docker images by digest, not tag, to prevent surprise base-image updates
- Enable `ACTIONS_STEP_DEBUG` for verbose runner logs
- Use `action-tmate` to SSH into a failed runner for live debugging
- Always `TZ=UTC` to remove timezone flakes
Q42How can an `actions/cache` entry be poisoned, and how do you defend against it?
AdvancedSecurity
Answer
Cache scope is the attack surface. An entry written on a branch is readable by that branch and by branches created from it, and an entry written on the default branch is readable by every branch and by every pull request build in the repository. Nothing in `actions/cache` verifies contents; the key is the only identity, and whoever writes a key first owns what everyone else restores.
Combine that with a `pull_request` workflow that runs contributor code and also saves a cache, and a contributor can push a branch whose build writes a tampered `node_modules`, a patched compiler binary or a poisoned `~/.gradle`, under a key that a later privileged job restores and executes with a deploy token in scope. The defences are structural. Do not run cache-saving steps in workflows triggered by untrusted contributions: split `actions/cache` into `actions/cache/restore` on pull requests and `actions/cache/save` gated to the default branch.
Put an explicit version prefix plus a lockfile hash in the key so you can rotate the whole namespace, since entries are immutable and cannot be overwritten, only superseded or evicted. Never cache build outputs that are later deployed without a rebuild. And follow every restore with `npm ci` or the equivalent, which revalidates each package against the lockfile and so turns a tampered download directory into a checksum failure rather than executed code.
# Untrusted PR builds: restore only, never write the shared cache
- uses: actions/cache/restore@v4
with:
path: ~/.npm
key: v3-${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: v3-${{ runner.os }}-npm-
- run: npm ci # revalidates every package against the lockfile
# Only the default branch is allowed to publish a cache entry
- uses: actions/cache/save@v4
if: github.ref == 'refs/heads/main'
with:
path: ~/.npm
key: v3-${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
Key Points
- Default-branch caches are readable by every branch in the repo
- Split restore and save; save only from trusted refs
- Version the key prefix, because entries are immutable
- Always re-run `npm ci` after a restore so tampering fails checksum validation
Q43How do you publish to npm or PyPI from Actions without storing a long-lived registry token?
AdvancedSupply Chain
Answer
Both registries accept the same GitHub OIDC token you already use for cloud auth, so the long-lived `NPM_TOKEN` or PyPI API token can be deleted outright. For PyPI you register a Trusted Publisher on the project, naming the owner, repository, workflow filename and optionally a GitHub environment, then run `pypa/gh-action-pypi-publish` in a job with `permissions: id-token: write` and no token at all; the action exchanges the OIDC assertion for a short-lived upload token. Binding the publisher to an environment is the hardening step that matters, because it lets you put required reviewers in front of every release.
For npm, trusted publishing arrived in 2025: you configure the package on npmjs.com to trust a specific repository and workflow, ensure a recent npm CLI in the job since the runner image may ship an older one, and publish with `id-token: write` and no `NODE_AUTH_TOKEN`. The same permission enables `npm publish --provenance`, which attaches a signed statement linking the tarball to the exact commit and workflow run and surfaces a provenance badge on the package page. Failure modes to name in an interview: a missing `id-token: write` produces a 401 that reads like a bad token; renaming the workflow file breaks the trust binding because the filename is part of the matched subject; and moving the publish step into a reusable workflow changes the claim, so the trusted publisher must reference the workflow that actually executes.
name: Release
on:
release:
types: [published]
jobs:
npm:
runs-on: ubuntu-latest
environment: release # required reviewers gate every publish
permissions:
id-token: write # OIDC exchange + provenance
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: https://registry.npmjs.org
- run: npm install -g npm@latest # trusted publishing needs a recent CLI
- run: npm ci && npm publish --provenance --access public
pypi:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- run: pipx run build
- uses: pypa/gh-action-pypi-publish@release/v1 # no token configured
Q44What has to change in your workflows to support a GitHub merge queue (`merge_group`)?
AdvancedWorkflow Design
Answer
A merge queue takes each approved pull request, creates a temporary `gh-readonly-queue/<base>/pr-<n>-<sha>` branch containing that PR merged on top of the current queue head, runs the required checks there, and fast-forwards the base branch only if they pass. That means CI has to handle a fourth event, `merge_group`, alongside `push` and `pull_request`. A workflow that triggers only on `pull_request` never runs inside the queue, and because branch protection matches required checks by job name, the queue then waits forever for a run that will never be created, which is the classic first-day failure.
So every required workflow needs `merge_group:` added to its `on:` block, and every condition keyed on `github.event_name == 'pull_request'` needs auditing. Contexts shift too: `github.ref` is the queue branch, `github.head_ref` is empty, and the real information lives in `github.event.merge_group.base_ref` and `head_sha`, so concurrency groups and deploy conditions written around `head_ref` misbehave. Use the split deliberately, since the queue exists to catch semantic conflicts between PRs that each pass alone: keep the fast suite on `pull_request` and put the expensive integration suite on `merge_group`. Tune the speculative build depth and merge method in the ruleset, because a deep queue combined with flaky tests ejects pull requests repeatedly and costs more minutes than the serialisation saves.
name: CI
on:
pull_request:
merge_group: # without this the queue blocks forever
push:
branches: [main]
concurrency:
# head_ref is empty inside a merge group, so fall back to ref
group: ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
fast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint && npm test
integration:
# expensive suite runs only where PRs are actually combined
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test:integration
Q45How do you generate and verify build provenance attestations for artifacts built in Actions?
AdvancedSupply Chain
Answer
`actions/attest-build-provenance` produces an in-toto SLSA provenance statement describing what was built, from which commit and by which workflow run, signs it through Sigstore using the job's OIDC identity so no signing key exists anywhere to be stolen, and records it in a transparency log for public repositories. The job needs `permissions: id-token: write`, `attestations: write` and `contents: read`, and you point the action at either a file with `subject-path:` or an image with `subject-name:` plus `subject-digest:`. Attestations are stored against the repository and checked with `gh attestation verify <artifact> --repo owner/name`, which fails unless the artifact's digest matches a recorded statement, so a tampered binary or a rebuild from a different commit is rejected rather than quietly accepted.
For container images the attestation can be pushed to the registry alongside the image, which is what admission controllers such as Kyverno or the Sigstore policy-controller evaluate at deploy time. This matters in 2026 because SLSA Build Level 3 requires provenance generated by a build service the developer cannot tamper with, and a GitHub-hosted runner plus a workflow identity satisfies that in a way a self-hosted runner your own team administers does not. The usual implementation mistake is attesting the artifact before the final packaging or compression step, so the digest you signed is not the digest you ship, and verification fails for consumers while passing in CI.
permissions:
id-token: write # Sigstore keyless signing
attestations: write # store the attestation on the repo
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build && tar -czf app.tar.gz dist/
# attest the FINAL artifact, after packaging, not before
- uses: actions/attest-build-provenance@v2
with:
subject-path: app.tar.gz
- uses: actions/upload-artifact@v4
with: { name: app, path: app.tar.gz }
# Consumer side, on any machine with gh installed:
# gh attestation verify app.tar.gz --repo goodspaceai/webapp
Key Points
- Keyless Sigstore signing bound to the workflow's OIDC identity
- Needs `id-token: write` plus `attestations: write`
- Verify with `gh attestation verify --repo owner/name`
- Attest the shipped artifact, after packaging, or digests will not match
Frequently Asked Questions
Is GitHub Actions free for private repositories?
There is a free tier, 2000 minutes/month on private repos for the Free plan, 3000 for Pro, 50,000 for Enterprise. Beyond that, Linux runners are $0.008/minute, Windows is 2× that, macOS is 10× (in 2026). For most early-stage Indian startups (under 5 engineers, light CI load), the free tier is enough. At 10+ engineers shipping daily, you will exceed it and either pay per-minute or move to self-hosted runners on EKS/GKE.
How much does a GitHub Actions / DevOps engineer earn in India?
₹6-22 LPA in 2026 for engineers with CI/CD as a primary skill. The range depends heavily on what you pair it with: junior CI/CD engineers ₹6-10 LPA, mid-level DevOps with GitHub Actions + Kubernetes + AWS ₹14-18 LPA, senior platform engineers building internal developer platforms ₹20-22+ LPA. Companies hiring: Razorpay, Swiggy, Zerodha, Postman, CRED, Freshworks, Zomato. Fintech and ML-heavy shops pay at the top end.
Should I use GitHub Actions or Jenkins for a new project in 2026?
GitHub Actions, unless you have an existing Jenkins investment with custom plugins you'd have to rewrite. The integration with PRs, secrets, environments, and the Marketplace ecosystem makes Actions the default for greenfield projects. Jenkins still wins for very complex multi-cluster pipelines with heavy custom-plugin requirements, but those use cases are rare in product startups.
How do I handle very long-running builds (1+ hour) in GitHub Actions?
Default GitHub-hosted runners have a 6-hour job limit, 35-day workflow limit. For builds approaching that, options are: (a) split into multiple jobs that pass artifacts (parallel where possible), (b) use larger runners (4-64 vCPU) which complete in proportionally less wall time, (c) move to self-hosted runners on Kubernetes, where the per-job cap rises from 6 hours to 5 days and spin-up is much faster. Heavy ML training jobs are the most common offender, most teams move those to a dedicated GPU runner pool.
What's the most common GitHub Actions security mistake?
Using `pull_request_target` and checking out the PR head SHA. This runs untrusted code with full repo secrets and was the root cause of multiple high-profile CI compromises in 2022-23. Runner-up: not pinning third-party Actions to SHAs, which exposes you to tag-rewrite supply-chain attacks like the 2025 `tj-actions/changed-files` incident. Both are well-documented in the official GitHub security hardening guide and should be the first audit you run on any new repo. A close third is leaving `permissions:` unset at the workflow level, which gives every job write access by default, easy fix, big blast-radius reduction.
Can I run GitHub Actions on ARM-based runners in 2026?
Yes. GitHub rolled out Linux ARM64 runners (`ubuntu-24.04-arm`, `ubuntu-22.04-arm`) to general availability in 2024-25, and they are widely used in 2026. ARM runners are about 30-40% cheaper per minute than equivalent x86 runners and noticeably faster for Node, Go, Rust, and JVM workloads (which all have first-class ARM toolchains now). The main caveat: any native code dependency must be available for ARM (`node-canvas`, certain prebuilt npm modules). Most teams use ARM for Linux Docker image builds (cross-arch via `docker/build-push-action` and QEMU is slow) and stay on x86 only where a dependency forces it. Self-hosted ARM is also straightforward on AWS Graviton instances via ARC.
Introduction
GitHub Actions has become the default CI/CD platform for Indian startups in 2026. It is now the usual first choice for builds, tests, and deployments on any project already hosted on GitHub, and it has displaced Jenkins, CircleCI, and Travis on most greenfield work. Its tight integration with GitHub means a single YAML file unlocks build pipelines, security scans, deploy gates, and even Issue automation.
If you are interviewing for a DevOps, SRE, or full-stack role at companies like Razorpay, Swiggy, Zerodha, Postman, or CRED, expect deep questions on workflow syntax, secret handling, OIDC cloud authentication, reusable workflows, matrix builds, caching strategies, and the security model around fork PRs and self-hosted runners.
This guide covers the 45 most-asked GitHub Actions interview questions in 2026, structured by difficulty from basic workflow syntax through intermediate pipeline design to advanced supply-chain and scaling topics. Each answer includes the underlying mechanics, the failure modes that show up in production, and a YAML or shell example where it clarifies the concept.
Ready to practice GitHub Actions interviews?
Don't just read, practice these GitHub Actions questions live with an AI interviewer that asks follow-ups and scores your answers.