GitHub Actions Interview Questions
Comprehensive interview preparation guide focused on GitHub Actions. Master self-hosted runners, matrix strategies, environments, caching, OIDC authentication, and real-world CI/CD scenarios.
A GitHub Actions workflow consists of several key components: Events trigger the workflow (push, pull_request, schedule, workflow_dispatch). Jobs are groups of steps that run on the same runner. Steps are individual tasks that can run shell commands or use actions. Actions are reusable units of code. Runners are the servers that execute jobs (GitHub-hosted or self-hosted).
Workflows are defined in YAML files stored in .github/workflows/. Each workflow can have multiple jobs that can run in parallel or sequentially using the needs keyword.
A workflow is the complete automation—the YAML file that defines what happens, when it happens, and in what order. A workflow can contain multiple jobs and steps. An action is a reusable unit of code that performs a specific task. Actions are the building blocks used within workflow steps.
Actions can be created in three ways: Docker containers, JavaScript/TypeScript, or composite run steps. They can be shared via the GitHub Marketplace or referenced directly from a repository. Workflows use actions with the uses keyword.
Think of workflows as recipes and actions as ingredients—workflows orchestrate, actions execute specific tasks.
Within the same job, steps share the same runner environment, so data can be passed via the file system. One step can write a file, and a subsequent step can read it. Environment variables also persist across steps—set them with echo "MY_VAR=value" >> $GITHUB_ENV.
For more structured data, use step outputs. A step can set outputs using echo "value=result" >> $GITHUB_OUTPUT. Other steps in the same job can reference these outputs using steps.step_id.outputs.value.
For complex data or large files, writing to disk is often the simplest approach.
# Setting output in a step
- name: Generate output
id: generator
run: echo "build_version=1.2.3" >> $GITHUB_OUTPUT
# Using the output in a later step
- name: Use output
run: echo "Version is ${{ steps.generator.outputs.build_version }}"
A matrix strategy allows you to run the same job across multiple combinations of variables. Define a strategy.matrix with the variables you want to vary—operating systems, language versions, browser versions, or any custom parameters. GitHub Actions creates a separate job for each combination.
Matrix is invaluable for testing libraries across multiple environments. You can access the current matrix values using ${{ matrix.variable-name }} within the job. The fail-fast setting (default true) cancels all in-progress matrix jobs when one fails. Set fail-fast: false to see all failures.
You can also exclude specific combinations using exclude or include additional ones with include.
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
Dynamic matrices allow you to generate the matrix from a previous job's output. First, a job outputs a JSON string of the matrix combinations. Then, the matrix job uses fromJSON(needs.previous.outputs.matrix) to set the matrix.
This is useful for: running tests only on changed directories, generating matrix from API responses, or limiting test runs based on commit size. The output must be a valid JSON array of objects.
Dynamic matrices are powerful but add complexity. They're best when you need to reduce CI time by only testing relevant combinations.
# Generate matrix dynamically
- name: Generate matrix
id: set-matrix
run: |
matrix=$(python scripts/get_changed_services.py)
echo "matrix=$matrix" >> $GITHUB_OUTPUT
# Use dynamic matrix
needs: generate-matrix
strategy:
matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }}
Self-hosted runners introduce significant security considerations. The most critical practice is using ephemeral runners—runners that are destroyed after each job. This prevents cross-job contamination and credential leakage.
Other security practices: run each job in an isolated environment (separate VM or container), minimize runner permissions using service accounts with scoped access, never store long-lived secrets on runners (use OIDC instead), keep runner software updated, and restrict which repositories can use the runner using runner groups.
For public repositories, avoid self-hosted runners entirely unless you implement strong isolation. Public workflows can execute arbitrary code—a compromised runner could expose your entire infrastructure.
Dynamic scaling adds runners when jobs are pending and removes them when idle. Popular solutions include:
actions-runner-controller for Kubernetes - The official Kubernetes operator that runs runners as pods and scales based on job queue length.
VM autoscaling groups - Use cloud provider autoscaling (AWS Auto Scaling, Azure VM Scale Sets) with custom scripts that register runners and shut down after jobs complete.
GitHub Actions Runner Controller (ARC) - Supports horizontal pod autoscaling (HPA) based on custom metrics like pending jobs.
Serverless ephemeral runners - Some solutions use AWS Lambda or similar, though time limits can be restrictive.
Runner groups organize self-hosted runners at the organization level. They provide access control—you can specify which repositories can use runners in a group. This is essential for multi-team organizations where different teams have different infrastructure needs.
Runner groups also allow you to set policies like maximum job timeout, idle time before shutdown, and workflow restrictions. Workflows target specific runner groups using the runs-on label with group names.
For example, create a "gpu-runners" group for ML workloads, restricted to the ml-team repository. Only workflows from that repository can use those expensive GPU runners.
Environments represent deployment targets like production, staging, or development. They provide several protection features: required reviewers (manual approval gates), branch restrictions (only specific branches can deploy), and custom protection rules via GitHub Actions.
Each environment has its own set of secrets and variables. Production secrets are not accessible to staging workflows, preventing accidental exposure. Deployment logs are tracked per environment, providing an audit trail.
To use an environment, reference it in your job: environment: production. The job will pause until all protection rules are satisfied.
GitHub Actions provides encrypted secrets that you store at the repository or organization level. Secrets are never exposed in logs—any attempt to print them is automatically redacted with ***. Use ${{ secrets.SECRET_NAME }} to reference them in workflows.
Best practices: never hardcode secrets in workflow files, use environment-specific secrets (different values for prod vs staging), rotate secrets regularly, use OIDC instead of secrets when possible, and limit secret scope to the minimum necessary repositories.
For GitHub Apps, use installation tokens instead of personal access tokens. For cloud providers, prefer OIDC authentication over long-lived secrets.
OpenID Connect (OIDC) allows GitHub Actions to authenticate to cloud providers without storing any long-lived secrets. GitHub generates a short-lived JSON Web Token (JWT) with claims about the workflow, repository, and branch. The cloud provider validates the token and returns temporary credentials scoped to specific permissions.
Benefits: no secrets to store or rotate, credentials expire after the job finishes, fine-grained permissions based on repository/environment/branch, and reduced risk of credential leakage. OIDC is supported by AWS, Azure, GCP, and many others.
This is the most secure authentication method for CI/CD pipelines.
The actions/cache action saves and restores files between workflow runs. You specify a path to cache and a key (typically a hash of relevant files). On subsequent runs, if the key matches, the cache is restored, saving significant time.
For package managers like npm, built-in caching is available through actions/setup-node with cache: 'npm'. Similarly for Python pip, Go modules, Maven, and others.
Use restore-keys as fallbacks when exact keys don't match. For example, use a branch-level key that falls back to the main branch's cache. Cache size is limited (10GB per repository), and caches expire after 7 days of no access.
Artifacts are files saved from a workflow run—build outputs, test reports, logs, or any files you want to persist. Use actions/upload-artifact to save artifacts and actions/download-artifact to retrieve them in downstream jobs.
Artifacts are perfect for passing build outputs between jobs (build → test → deploy), preserving test reports for later analysis, saving screenshots from UI tests, or storing logs for debugging. Artifacts expire after 90 days by default.
Unlike caching (which is automatic), artifacts require explicit upload. Artifacts are stored with the workflow run and can be downloaded from the GitHub UI or API.
Reusable workflows are defined in a YAML file with on: workflow_call. They can accept inputs and secrets, and produce outputs. Other workflows reference them using uses: owner/repo/.github/workflows/workflow.yml@ref.
This is powerful for standardizing CI/CD across an organization. For example, create a reusable "test" workflow that all repositories use. When you update the test workflow, all consuming repositories get the improvements automatically.
Reusable workflows can be called from any public repository, or from private repositories within the same organization. They help enforce consistency and reduce duplication.
# reusable-workflow.yml
on:
workflow_call:
inputs:
node-version:
required: true
type: string
secrets:
api-key:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm test
# Calling workflow
jobs:
call-test:
uses: myorg/shared-workflows/.github/workflows/test.yml@main
with:
node-version: '20'
secrets:
api-key: ${{ secrets.ORG_API_KEY }}
Use GitHub Environments with required reviewers to create manual approval gates. Define a "production" environment with required reviewers (one or more users/teams). When the workflow reaches the production deployment job, it pauses and sends notifications to the reviewers.
A reviewer approves or rejects the deployment via the GitHub UI. Once approved, the workflow continues automatically. This provides a safe deployment pipeline: test → staging (automatic) → production (requires approval).
Combine this with branch restrictions (only main can deploy to production) and status checks (tests must pass) for complete deployment safety.
First, enable step debugging by setting the secret ACTIONS_STEP_DEBUG=true. This adds verbose output. Also set ACTIONS_RUNNER_DEBUG=true for runner diagnostic information.
If that's insufficient, use SSH debugging. Add the mxschmitt/action-tmate action to the workflow. When the workflow reaches that step, it prints SSH connection details. You can connect, inspect the environment, run commands manually, and diagnose the issue interactively.
Common environment differences between CI and local: case-sensitive file systems (Windows vs Linux), different tool versions, missing environment variables, or file permissions. Use run: env to print all environment variables, and run: ls -la to inspect the file system.
This error indicates that the GITHUB_TOKEN doesn't have sufficient permissions for the operation you're attempting. By default, the token has only read permissions for most scopes.
Fix by explicitly setting permissions at the job or workflow level. For example, to push to packages, add packages: write. To create issues, add issues: write. To comment on PRs, add pull-requests: write.
For actions that need higher permissions (like pushing to protected branches), you may need a personal access token with appropriate scopes instead of GITHUB_TOKEN.
A monorepo CI pipeline needs to be efficient—only test changed services, not everything. Use path filters to trigger workflows only when relevant files change. For example, only run frontend tests when frontend files change.
Use a dynamic matrix to generate test jobs based on changed services. A first job determines which services changed using git diff and outputs a JSON matrix. Downstream jobs use that matrix to run tests only on affected services.
Cache dependencies per service to avoid re-downloading. Use reusable workflows for common patterns to reduce duplication. Consider using actions/upload-artifact to pass build outputs between dependent services.
Blue-green deployment maintains two identical environments: blue (current) and green (new). Deploy to green, test, then switch traffic. Implementation steps:
First, deploy the new version to the green environment (e.g., new ECS task definition, new Kubernetes deployment). Run smoke tests against green to verify functionality. If tests pass, update the load balancer to route traffic to green. Finally, scale down the blue environment.
Use GitHub Actions to orchestrate these steps using cloud provider CLI tools. Store the green environment ID in an artifact so rollback can reference it. If tests fail, the pipeline stops and no traffic switch occurs—blue remains live.
For rollback, simply switch the load balancer back to blue. The same workflow can handle both deployment and rollback.
Several strategies reduce CI runtime: Parallelization - Use matrix strategies to split tests across multiple runners. Caching - Cache dependencies to avoid re-downloading. Path filtering - Only run relevant tests when specific directories change. Test splitting - Distribute tests across runners using tools like jest --shard or pytest-split.
Incremental builds - Only rebuild changed modules. Self-hosted runners with warm caches - Ephemeral runners with pre-populated caches. Fail fast - Stop CI on first test failure to save resources.
Set timeouts on jobs to prevent hanging. Use GitHub-hosted runners for standard tests and self-hosted for specialized needs. Monitor runtime trends to identify regressions.
Key risks include: Secret leakage - Secrets printed in logs or exposed in artifacts. Mitigate with automatic redaction, use OIDC, and audit logs.
Supply chain attacks - Malicious actions or dependencies. Mitigate by pinning action versions to full-length SHAs, using dependabot for updates, and reviewing action code.
Pull request attacks - Malicious code in PRs from forks. Mitigate with pull_request_target event (runs in base context) and restrict permissions for PRs from forks.
Self-hosted runner compromise - Mitigate with ephemeral runners, isolation, and minimal permissions.
Environment variable injection - Validate all external inputs, avoid using untrusted data in run: commands.
Pull requests from forks require special handling because they run in a different context with limited access. The pull_request_target event runs in the base repository's context with full permissions—use it carefully.
Best practices: Never check out the PR's code and run it without review. Instead, use pull_request_target to comment on the PR or run checks that don't execute untrusted code. For running tests, create a separate workflow triggered by pull_request (runs in fork context with no secrets).
Use github.event.pull_request.head.sha to reference the PR's commit. Add labels to mark PRs as safe after review. Never use secrets in PR workflows from forks.
Each version adds features and improvements. v2 introduced improved performance and persistence of Git credentials. v3 added better support for submodules and fetch depth. v4 added improved security with token scoping, better support for sparse checkouts, and improved performance for large repositories.
Always pin to a major version (like v4) to get patches automatically, or pin to a full SHA for maximum security. Newer versions are generally faster and more secure.
Use the schedule event with cron syntax. Example: on: schedule: - cron: '0 2 * * *' runs daily at 2 AM UTC. Schedules run on the default branch (usually main).
Scheduled workflows are useful for nightly tests, dependency updates, security scans, and report generation. Note that scheduled workflows may be delayed if many are scheduled at the same time.
For testing scheduled workflows, use workflow_dispatch to trigger them manually.
Use the if conditional at the job or step level. Conditions can check GitHub context variables, environment variables, outputs from previous steps, or custom expressions.
Common examples: if: github.ref == 'refs/heads/main' (only on main branch), if: steps.test.outcome == 'success' (only if previous step succeeded), if: matrix.os == 'ubuntu-latest' (only for specific matrix value).
Use github.event_name to condition on event type. The always() function runs even on failure, and cancelled() runs when the workflow was cancelled.
- Practical experience: Describe actual workflows you've built and problems you've solved.
- Security mindset: Discuss secret management, OIDC, and secure self-hosted runner setup.
- Performance optimization: Explain how you reduced CI runtime with caching, parallelization, and path filtering.
- Real-world patterns: Share examples of blue-green deployments, monorepo strategies, or reusable workflows.
- Troubleshooting approach: Walk through how you debug failed workflows step by step.
GitHub Actions is the backbone of modern CI/CD on GitHub. Master these concepts to demonstrate your expertise in automating, testing, and deploying with confidence.