GitHub Actions interviews cover workflows, runners, and the CI/CD patterns that come up most for teams already living in GitHub.
A workflow is the top-level automation defined in a YAML file under `.github/workflows/`, and it's triggered by some event like a push or a pull request. Each workflow contains one or more jobs, and jobs run in parallel by default unless you use `needs` to create explicit dependencies between them. Each job gets its own fresh runner instance and consists of a sequence of steps that execute in order on that same runner, sharing its filesystem and environment for the duration of the job. Steps either run shell commands directly or invoke reusable actions via `uses:`. If a step fails, the remaining steps in that job are skipped by default, though you can override that behavior with `continue-on-error` or conditional `if` expressions.
Triggers are defined under the `on:` key, and the most commonly used ones are `push`, `pull_request`, `schedule`, and `workflow_dispatch`. `push` fires when commits land on specified branches or tags, and you can filter by branch, tag, or path patterns so it doesn't run on irrelevant changes. `pull_request` triggers on PR activity like opening or syncing and runs against a merge commit between the PR branch and the base, which matters for what permissions and secrets are available, especially from forks. `schedule` uses standard cron syntax for timed runs like nightly builds, though GitHub doesn't guarantee exact timing when the platform is under load. `workflow_dispatch` lets you manually kick off a run from the UI or API with optional input parameters, which is handy for on-demand or approval-gated deployments.
GitHub-hosted runners are ephemeral VMs that GitHub provisions, patches, and tears down after every job, coming preloaded with common tooling like Node, Python, and Docker across Ubuntu, Windows, and macOS images. They require zero maintenance, but you're constrained by fixed hardware specs, per-minute billing past the free tier, and no persistent state between runs. Self-hosted runners are machines you register yourself, whether on-prem or in your own cloud, which gives you control over hardware, networking, and access to internal resources behind a firewall. They shift the maintenance burden to you but can be much cheaper at high volume and are necessary for specialized needs like GPU workloads. A real operational concern with self-hosted runners is security, since attaching one to a public repo or letting it run untrusted PR code can expose both the runner and your internal network.
Marketplace actions are prebuilt, reusable steps published by GitHub, vendors, or the community, like `actions/checkout` or `actions/setup-node`, that you drop into a workflow via `uses:` to handle common tasks without writing that logic yourself. They save time and are generally well-maintained, but you should pin them to a commit SHA or fixed version tag rather than a floating branch to avoid supply-chain risk if the action changes underneath you. Custom actions come in three flavors: JavaScript actions that run directly on the runner through Node, Docker container actions that ship their own environment, and composite actions that just bundle a sequence of existing steps. You'd write a custom action when you have organization-specific logic worth packaging and reusing across many repos instead of copy-pasting the same steps everywhere. A custom action lives in its own repo with an `action.yml` file describing its inputs, outputs, and entry point.
Secrets are encrypted values stored at the repository, environment, or organization level, and they're injected into a workflow at runtime through the `secrets` context, referenced like `${{ secrets.MY_API_KEY }}`. GitHub automatically redacts any secret value it sees printed in logs, replacing it with `***`, but that masking is just string matching, so you shouldn't rely on it if you're transforming or encoding the secret in a way that could dodge the filter. Secrets aren't passed to workflows triggered by a `pull_request` from a forked repository by default, which deliberately prevents someone from exfiltrating your secrets through a malicious PR. Environment-level secrets are the right choice for deployment credentials since they can be locked behind protection rules and required reviewers. It's also worth knowing about `GITHUB_TOKEN`, a short-lived token GitHub generates automatically and scopes to the repo, letting you make authenticated API calls without managing a personal access token.
A matrix build runs the same job multiple times against different combinations of variables, like language versions or operating systems, defined under `strategy.matrix` in a job. GitHub Actions expands that matrix into a separate parallel job for every combination, so a matrix of `node: [14, 16, 18]` crossed with `os: [ubuntu-latest, windows-latest]` produces six independent job runs automatically. This is the standard way to verify cross-version and cross-platform compatibility without hand-duplicating the job definition for every case. You can refine it with `include` to add one-off extra combinations or `exclude` to drop pairings that don't apply, and `fail-fast: false` to let every matrix job finish even after one fails, which is useful when you want the complete picture of what broke. `max-parallel` is also available if you need to throttle concurrency, say to avoid hammering a shared external service.
The `actions/cache` action persists files between workflow runs, most often dependency directories like `node_modules`, a pip cache, or Maven's `.m2` folder, so you're not reinstalling everything from scratch on every run. You define a cache key, typically a hash of a lockfile like `package-lock.json`, so the cache only gets reused when dependencies actually haven't changed, and `restore-keys` can act as a fallback for a partial match if there's no exact hit. When the key doesn't match, the job runs normally and saves a fresh cache at the end, so subsequent runs benefit from it. Caches get evicted after seven days of no access or once the repo's total cache storage exceeds its limit. Many setup actions, like `actions/setup-node`, have caching built in through a `cache` input, so you often don't need to configure `actions/cache` by hand at all.
Artifacts are files a workflow run produces, like build outputs, test reports, or coverage data, uploaded with `actions/upload-artifact` and retrievable from the Actions UI, the API, or a later job in the same workflow via `actions/download-artifact`. The core distinction from caching is intent: caching exists purely to speed up future runs by reusing unchanged dependencies, while artifacts exist to preserve the actual output of one specific run for a human or downstream process to use. Artifacts are retained for a configurable window, 90 days by default, and are tied to that particular run rather than matched across runs by a key. A common pattern is building in one job and passing a compiled binary or a Docker image tarball to a downstream deployment job as an artifact, since separate jobs don't share a filesystem. Artifacts also show up as downloadable files right on the run summary page, which is convenient for things like publishing an HTML coverage report.
Environments, like `staging` or `production`, let you attach protection rules to a deployment target so any job that references that environment has to satisfy certain conditions before it's allowed to proceed. The most common rule is required reviewers, where the job pauses and waits for a designated person to manually approve it in the UI, which is exactly the gate you want before something ships to production. You can also restrict which branches are permitted to deploy to a given environment and set a wait timer that delays the job for a fixed number of minutes, giving you a window to cancel an accidental deploy. Environment-scoped secrets are only exposed to jobs referencing that environment, and only after its protection rules pass, adding another access-control layer beyond repo-level secrets. This mechanism is what lets one workflow safely promote a build through dev, staging, and production without hand-rolled manual gating.
The biggest practical difference is that GitHub Actions is native to GitHub, so workflows live alongside the code, trigger off GitHub-native events like PRs and issue comments, and need no separate server to install, whereas Jenkins is a standalone, self-hosted server you have to provision, patch, and scale yourself. GitLab CI sits in between, being just as tightly integrated as Actions but as part of the GitLab platform, and it configures an entire pipeline in a single `.gitlab-ci.yml` rather than per-workflow files. Jenkins has by far the most mature and extensive plugin ecosystem, built up over more than a decade, making it common in complex or highly customized enterprise setups, though that flexibility comes with real maintenance overhead and a steeper learning curve around Groovy-based pipeline scripts. GitHub Actions and GitLab CI both offer a much lower barrier to entry with straightforward YAML and generous hosted runner minutes. In practice the choice usually comes down to where the code already lives, since the tightest integration and lowest operational overhead comes from using the CI system native to your source control platform.
Reusable workflows let you define a full workflow, potentially with multiple jobs, in one repo and call it from another workflow using `uses:` at the job level, passing in `inputs` and `secrets` much like calling a function. Composite actions bundle a sequence of steps into a single reusable unit invoked as one step within a job, which makes them a better fit for smaller, step-level logic like a standardized checkout-build-lint sequence used across many workflows. The practical difference is granularity: reusable workflows operate at the job or multi-job level and can even define their own runner and strategy, while composite actions run inside an existing job and inherit that job's runner. If you need to orchestrate several dependent jobs as one callable unit, a reusable workflow is the only option, since composite actions can't spin up additional jobs. It's common to use both together, wrapping a composite action for a shared step sequence inside a reusable workflow that defines a shared pipeline shape.
`GITHUB_TOKEN` is an automatically generated, short-lived credential that GitHub Actions injects into every workflow run, scoped to the repository the workflow is executing in, and it's what actions like `actions/checkout` use under the hood to authenticate. By default it gets permissions derived from repository settings, but you should tighten that with an explicit `permissions` block at the workflow or job level, following least privilege, for example `contents: read` and `pull-requests: write` if the job only needs to read code and comment on a PR. The token automatically expires once the job finishes, so there's no long-lived credential sitting around to rotate or leak. It's distinct from a personal access token or a GitHub App token, which you'd reach for when a workflow needs to act across multiple repositories or do something `GITHUB_TOKEN` deliberately can't, like pushing a commit that triggers another workflow run. Getting this permissions block right is a frequent point of scrutiny in security reviews, since an overly broad default token is a real risk if a workflow ever executes untrusted code.
The `concurrency` key lets you define a named group that only one workflow run can occupy at a time, so if a new run starts while another with the same group key is still in progress, you can configure `cancel-in-progress: true` to automatically cancel the older one instead of letting both run to completion. A common pattern is keying the group on the branch or PR number, like `group: ci-${{ github.ref }}`, so pushing several commits to the same pull request in quick succession cancels the stale, now-outdated CI run for the earlier commit and only the latest one finishes, saving runner minutes and avoiding confusing status checks from an old commit. It's less commonly used without cancellation too, just to serialize deployments to the same environment so two people merging around the same time don't trigger overlapping production deploys. This is one of the simplest, highest-impact settings for controlling both cost and correctness in a busy repository.
Permissions for `GITHUB_TOKEN` can be declared at the top of the workflow file, applying as the default for every job in that workflow, or overridden per job by nesting a `permissions` block under that specific job, which takes precedence over the workflow-level setting for that job only. This lets you follow least privilege granularly — for example, a workflow might default to `contents: read` for most jobs, but a single release job that needs to create a GitHub release can be given `contents: write` just for itself without loosening permissions for every other job in the same workflow. If no `permissions` key is specified at all, the token falls back to whatever the repository or organization's default token permissions setting is, which can be surprisingly broad unless an administrator has already restricted it. Being explicit about permissions at the narrowest scope that still works is considered a security best practice specifically because workflows are a common target for supply-chain style attacks.
The first step is almost always reading the actual step logs in the Actions tab, expanding the failing step to see its full output, since most failures are a straightforward command error or a missing dependency visible right there. For more detail, re-running the job with debug logging enabled, either by setting the repository secrets `ACTIONS_RUNNER_DEBUG` and `ACTIONS_STEP_DEBUG` to `true` or using the 'Enable debug logging' option when re-running, surfaces much more verbose internal logging from the runner and each action. When a failure only reproduces on the runner and not locally, a useful trick is temporarily adding a step that runs `tmate` or a similar SSH-into-runner action, which pauses the job and gives you an interactive shell on the actual runner to poke around. It's also worth checking whether the failure is environment-specific, like a difference in tool versions between `ubuntu-latest` and what you have locally, since GitHub periodically updates the images and can introduce version drift. Finally, comparing a failing run against the last known-good run's logs side by side often reveals the specific change, whether in your code or in the workflow file, that broke things.