A deep dive into GitHub Actions

By

A practical deep dive into GitHub Actions: workflows, runners, job graphs, artifacts, caching, security, deployments, and debugging.

~~~

GitHub Actions lets you run automation directly from a GitHub repository.

GitHub Actions homepage showing a build, test, and publish workflow

Most people meet it through a small CI workflow. Push some code, GitHub installs the dependencies, runs the tests, and shows a green checkmark.

That is the easy part.

The details become important when you add multiple jobs, deployments, secrets, pull requests from forks, or reusable workflows. At that point, the YAML file is not just a list of commands. It describes machines, data flow, permissions, and trust boundaries.

In this deep dive, I want to build that mental model first. Then we will create a complete pipeline that tests, builds, stores an artifact, and deploys it.

If you want to work through the subject as a complete project, I also have a free GitHub Actions and CI/CD course.

The GitHub Actions mental model

A workflow starts with an event.

That event can be a push, a pull request, a release, a manual button, a schedule, or many other things GitHub knows about.

The event starts a workflow run. The workflow contains one or more jobs. Each job contains ordered steps.

The complete path looks like this:

event
  -> workflow run
      -> job
          -> step
          -> step
      -> job
          -> step

This distinction matters because state is shared at some levels and isolated at others.

Steps inside one job run on the same machine. They can see files created by earlier steps.

Jobs are different. Each job gets its own runner. A file created in one job does not appear in another job unless you explicitly pass it across.

GitHub describes all the available keys in the official workflow syntax reference. You do not need to memorize that reference. You need to know which boundary you are working with.

GitHub Actions workflow syntax reference in the GitHub documentation

Where workflows live

Workflow files go in .github/workflows/ and use YAML:

.github/
  workflows/
    ci.yml
    deploy.yml

A repository can have many workflows.

I prefer to separate CI from deployment when they have different triggers or permissions. A test workflow should not receive a production credential just because one deploy step needs it.

The filename does not control when the workflow runs. The on configuration inside the file does that.

Start with the smallest workflow

Here is a complete workflow:

name: Hello

on: push

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo 'Hello from GitHub Actions'

Commit this as .github/workflows/hello.yml and push it to GitHub.

A hello.yml GitHub Actions workflow open in an editor

The push event starts the workflow. GitHub creates an Ubuntu runner for the hello job, runs the shell command, records its output, and then removes the runner.

The hello key is the job ID. It only needs to be unique inside this workflow.

The job has one step. A step can run a shell command with run, or it can call a reusable action with uses.

Choose the right event

The on key can listen to several events:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

This workflow runs in three situations:

  • someone pushes to main
  • a pull request targets main
  • someone starts it manually from the Actions tab

Branch filters are different for push and pull_request.

For push, the filter describes the branch receiving the commit. For pull_request, it describes the target branch of the pull request.

You can also filter paths:

on:
  pull_request:
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'

Now a documentation-only change can skip the application workflow.

Be careful with required checks and path filters. If branch protection expects a workflow that never starts, a pull request can remain waiting for that check. Design the required-check rules and filters together.

GitHub documents the exact behavior of each event in Events that trigger workflows.

Manual inputs

workflow_dispatch can accept inputs. This is useful for a maintenance job or a controlled deployment:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Where to deploy
        required: true
        type: choice
        options:
          - staging
          - production

The chosen value is available through the inputs context:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to $DEPLOY_ENVIRONMENT"
        env:
          DEPLOY_ENVIRONMENT: ${{ inputs.environment }}

Notice how I pass the expression into an environment variable. The shell receives data through a normal shell boundary.

I avoid inserting user-controlled text directly into a long shell command. Pull request titles, issue bodies, branch names, and manual text inputs can all contain shell characters.

Scheduled workflows

A schedule uses cron syntax:

on:
  schedule:
    - cron: '0 6 * * *'

This requests a run every day at 06:00 UTC.

Scheduled workflows run from the default branch. The workflow file must exist there.

GitHub schedules are best effort. A run can start later during busy periods, and schedules can stop in an inactive public repository. Do not use GitHub Actions as an exact-time scheduler.

UTC creates another small trap. If you want 07:00 in Rome all year, daylight saving time changes the UTC offset. You need to account for both offsets inside the workflow or use a scheduler with timezone support.

I built a cron expression builder when I need to check an expression without doing the arithmetic in my head.

Contexts and expressions

GitHub Actions exposes workflow data through contexts.

The most common contexts are:

  • github for the repository, event, commit, branch, and workflow run
  • runner for the current runner
  • job for the current job
  • steps for step outcomes and outputs
  • needs for completed dependency jobs and their outputs
  • matrix for the current matrix combination
  • vars for configuration variables
  • secrets for secrets

Expressions use this syntax:

${{ expression }}

For example, this job runs only for a push to main:

jobs:
  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo 'deploying'

GitHub evaluates that if expression before sending the job to a runner.

Once the job starts, the shell has its own environment variables:

- run: echo "ref=$GITHUB_REF"

These two forms look similar, but they run at different times:

if: github.ref == 'refs/heads/main'

is evaluated by GitHub Actions.

if [ "$GITHUB_REF" = "refs/heads/main" ]; then
  echo 'main branch'
fi

is evaluated later by Bash on the runner.

The official contexts guide lists where each context is available. Context availability changes depending on the event and the part of the workflow being evaluated.

Runners and isolation

A runner is the machine that executes a job.

GitHub-hosted runners are the simplest option. GitHub provides Linux, Windows, and macOS images and maintains the machines.

With the exception of the smaller single-CPU runner, every GitHub-hosted job starts in a new virtual machine. The runner disappears when the job ends.

This has several consequences:

  • the repository is not present until you check it out
  • installed dependencies do not survive the job
  • files survive between steps in the same job
  • files do not survive between separate jobs
  • the preinstalled software can change when a runner image changes

Here is why most workflows start with actions/checkout:

steps:
  - uses: actions/checkout@v7
  - run: ls

Without the checkout step, the runner does not have your repository files.

You can also run your own machine as a self-hosted runner. That gives you more control, access to private networks, and possibly faster builds.

It also creates a bigger security responsibility. The machine can keep files and processes between jobs. Untrusted pull request code should not run on a persistent runner that can reach production systems.

My default is GitHub-hosted runners. I choose self-hosted runners only when the workload genuinely needs special hardware, private network access, or a controlled machine image.

Build a reproducible Node.js CI job

Let’s create a useful CI workflow for a Node.js project:

name: CI

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: npm

      - run: npm ci
      - run: npm test
      - run: npm run build

actions/setup-node installs the declared Node.js version. This keeps the workflow independent from the version currently preinstalled on the runner image.

The cache: npm option caches npm’s download cache. It does not cache node_modules.

npm ci installs the exact dependency tree recorded in package-lock.json. It fails when the lockfile and package.json disagree. That is useful in CI because it catches an incomplete dependency update.

The commands should also work locally. I keep real build logic in package.json scripts or normal project scripts, not in 80 lines of workflow-only shell code.

YAML should coordinate the work. The project should define how it tests and builds.

Steps share one workspace

All steps in a job use the same workspace.

This works because the second step can see report.txt:

steps:
  - run: npm test > report.txt
  - run: wc -l report.txt

Environment changes need a special file. A shell process cannot change the parent process environment after it exits.

GitHub provides $GITHUB_ENV for values needed by later steps:

steps:
  - run: echo 'APP_ENV=staging' >> "$GITHUB_ENV"
  - run: echo "$APP_ENV"

The new value is available to later steps, not to the step that writes it.

Use a normal job-level env key when the value is known in advance:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: test

Jobs form a dependency graph

Jobs run in parallel by default.

That is useful when linting and tests do not depend on each other:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci
      - run: npm test

Add needs when one job must wait for another:

jobs:
  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

The build job starts only after both required jobs succeed.

Remember that build gets a fresh runner. It does not inherit the checkout or installed dependencies from lint or test. The abbreviated example above needs its own setup steps in a real workflow.

I design jobs around independent outcomes, not individual commands. Splitting a three-second lint command into its own job can cost more runner startup time than it saves.

Pass small values between jobs

A step can create an output by writing to $GITHUB_OUTPUT:

steps:
  - id: version
    run: |
      VERSION=$(node -p "require('./package.json').version")
      echo "value=$VERSION" >> "$GITHUB_OUTPUT"

Later steps in the same job can read it:

- run: echo "version=${{ steps.version.outputs.value }}"

To pass the value to another job, expose it as a job output:

jobs:
  package:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.value }}
    steps:
      - uses: actions/checkout@v7
      - id: version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          echo "value=$VERSION" >> "$GITHUB_OUTPUT"

  release:
    needs: package
    runs-on: ubuntu-latest
    steps:
      - run: echo "releasing $RELEASE_VERSION"
        env:
          RELEASE_VERSION: ${{ needs.package.outputs.version }}

Outputs are good for small strings such as a version, an artifact ID, or a generated URL.

Use an artifact for files.

Artifacts are not caches

Both features store files, but they solve different problems.

A cache makes future workflow runs faster. The job must still work when the cache is missing.

An artifact preserves an output from a workflow run. Another job can download it, or a developer can inspect it later.

Good cache contents include package manager downloads and rebuildable compiler data.

Good artifacts include:

  • the application build
  • test reports
  • screenshots from failed browser tests
  • coverage reports
  • logs needed to diagnose a failure

This build job stores a static site:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: npm
      - run: npm ci
      - run: npm run build

      - uses: actions/upload-artifact@v7
        with:
          name: site
          path: dist/
          if-no-files-found: error
          retention-days: 7

Another job can download that exact build:

jobs:
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: site
          path: dist/
      - run: find dist -maxdepth 2 -type f | head

The deploy job does not rebuild the site. It deploys the output already produced by the verified build job.

That gives us a useful release property: the bytes we deploy are the bytes we tested.

GitHub’s dependency caching guide also warns that restored caches are untrusted input. Never put secrets in a cache.

Use a matrix for real compatibility requirements

A matrix creates several versions of one job.

A Node.js library might support Node 22 and 24:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node: [22, 24]

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test

GitHub creates two independent jobs.

fail-fast: false lets both finish when one version fails. This gives you the complete compatibility result.

You can add operating systems too:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [22, 24]

runs-on: ${{ matrix.os }}

That creates six jobs.

Do this only when the project promises support for those combinations. A matrix multiplies time, cost, and failure noise very quickly.

For an application deployed on Linux, I usually test the production Node.js version on Linux. A library with cross-platform users has a stronger reason for a larger matrix.

Conditions and failure behavior

A step runs after previous steps succeed unless you change its condition.

You can run a diagnostic upload even after tests fail:

- uses: actions/upload-artifact@v7
  if: failure()
  with:
    name: test-results
    path: test-results/
    if-no-files-found: ignore

Useful status functions include:

  • success()
  • failure()
  • cancelled()
  • always()

Use always() carefully. Cleanup and reporting are good uses. A critical step that should stop after cancellation usually needs a more precise condition.

You can also stop one non-critical step from failing the job:

- run: npm run optional-report
  continue-on-error: true

Do not use continue-on-error to hide a required test. A green workflow must still mean something.

I also set timeouts on commands that can hang:

- run: npm test
  timeout-minutes: 15

Control overlapping runs

Two quick pushes can start two copies of the same workflow.

For CI, the old result is often no longer useful. Cancel it when a newer commit arrives:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

The workflow name keeps this group separate from other workflows. The ref keeps branches separate from each other.

Deployments need a different decision.

Cancelling a deploy halfway through can leave the target in an unknown state. If the deploy command is not safely interruptible, queue production deploys instead:

concurrency:
  group: production-deploy
  queue: max

Now production deploys run one at a time.

Concurrency is part of release design. Decide whether an old run should be cancelled, skipped, or allowed to finish.

Variables, secrets, and environments

GitHub Actions has several kinds of values:

  • workflow env values live in the YAML
  • configuration variables use the vars context
  • secrets use the secrets context
  • environment secrets belong to a deployment environment

A public application URL is configuration, not a secret:

env:
  APP_URL: https://flaviocopes.com

An API token is a secret:

- run: npm run deploy
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Add repository secrets under Settings → Secrets and variables → Actions.

GitHub masks matching secret values in logs, but masking is not a security boundary. A transformed, encoded, or split value may not be redacted. Do not print secrets.

Secrets are not passed to normal workflows triggered by pull requests from forks. That prevents untrusted code from reading a repository secret.

For production, create an environment such as production:

jobs:
  deploy:
    environment:
      name: production
      url: https://flaviocopes.com

An environment can restrict branches, hold separate secrets, add a wait timer, and require approval. Environment secrets become available only after its protection rules pass.

The official deployments and environments reference lists the rules available for each GitHub plan.

Minimize GITHUB_TOKEN permissions

Every job receives a GITHUB_TOKEN. Actions can use it to call GitHub APIs or interact with the repository.

Do not rely on an account-wide default. Declare the permissions the workflow needs:

permissions:
  contents: read

When you specify one permission, unspecified permissions become none.

You can override permissions for one job:

jobs:
  release:
    permissions:
      contents: write

This job can create a release or tag. The test job does not need that authority.

A deployment using OpenID Connect needs this permission:

permissions:
  contents: read
  id-token: write

id-token: write does not directly grant cloud access. It lets the job request an identity token. The cloud provider must trust a matching repository, workflow, branch, or environment.

When a provider supports it, I prefer OpenID Connect over a long-lived cloud key. The workflow receives a short-lived credential for that run instead of storing a permanent administrator token in GitHub.

Treat actions as dependencies

This line executes code maintained in another repository:

- uses: owner/action@v3

That action can read the workspace, access the network, and use any credential available to the job.

Review third-party actions before adding them. Check the owner, source code, release history, inputs, and requested permissions.

Major version tags are readable in tutorials:

- uses: actions/checkout@v7

For a security-sensitive workflow, pin external actions to a reviewed full commit SHA:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

A full SHA is immutable. A tag can move.

Dependabot can update GitHub Actions references. I built a Dependabot configuration generator if you want a small starting file for that.

Pull requests are a security boundary

A workflow often executes repository code:

- run: npm ci
- run: npm test

A pull request can change package.json, install scripts, tests, build scripts, and application code. Treat all of that as untrusted until reviewed.

The normal pull_request event uses restricted access for forked pull requests and does not expose repository secrets.

pull_request_target is different. It runs in the context of the base repository and can receive privileged access.

Never use pull_request_target to check out and execute untrusted pull request code in a job with write permissions or secrets.

The same rule applies to event data. A pull request title is attacker-controlled text. Pass it through an environment variable and quote it instead of placing it directly inside a shell script.

GitHub keeps a current secure use reference. Read it before building a workflow that mixes untrusted contributions and privileged automation.

Reuse the right layer

Duplication appears in workflows at different levels.

I use three reuse boundaries:

  1. a project script for commands that should also run locally
  2. a composite action for a repeated group of steps
  3. a reusable workflow for complete jobs and policy

Start with a script:

{
  "scripts": {
    "check": "npm run lint && npm test && npm run build"
  }
}

The workflow stays small:

- run: npm run check

A composite action packages several steps and runs as one step inside a job.

A reusable workflow can define several jobs, runners, permissions, inputs, secrets, and outputs. Another workflow calls it at the job level.

Use a reusable workflow when several repositories must follow the same deployment policy. Use a composite action when several workflows repeat the same setup steps.

GitHub has a useful comparison in Reusing workflow configurations.

Do not abstract a workflow after seeing the same three lines once. Extract behavior when it is stable and the shared boundary is clear.

Run tests with a service container

Integration tests may need PostgreSQL, Redis, or another service.

GitHub Actions can start a service container next to a Linux job:

jobs:
  integration:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: test-password
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v7
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:test-password@localhost:5432/app_test

The test connects to PostgreSQL on localhost:5432 because this job runs directly on the runner and maps the container port.

GitHub starts and removes the service container with the job. The service container guide explains the networking difference when the job itself also runs in a container.

Use a test-only password here. Do not copy a production database credential into an integration test job.

A complete test, build, and deploy workflow

Now let’s combine the pieces.

This workflow tests supported Node.js versions, builds the site once, stores the build as an artifact, and deploys that same artifact from main:

name: CI

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node: [22, 24]

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci
      - run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: npm

      - run: npm ci
      - run: npm run build

      - uses: actions/upload-artifact@v7
        with:
          name: site-${{ github.sha }}
          path: dist/
          if-no-files-found: error
          retention-days: 7

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest

    concurrency:
      group: production-deploy
      queue: max

    environment:
      name: production
      url: https://flaviocopes.com

    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          package-manager-cache: false

      - run: npm ci

      - uses: actions/download-artifact@v8
        with:
          name: site-${{ github.sha }}
          path: dist/

      - run: npm run deploy:ci
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Let’s follow one pull request through it.

The pull request starts two test jobs. One uses Node 22 and one uses Node 24.

A new push to the same pull request cancels the old run. A run from main is allowed to finish, so a newer commit cannot interrupt a production deployment halfway through.

When both pass, the build job starts on a fresh runner. It installs from the lockfile, builds the site once, and uploads dist/.

The deploy condition is false for a pull request, so production is untouched.

After the pull request merges, the push to main starts the workflow again. Tests and the build run. The deploy job then waits for any protection rules on the production environment.

The job downloads the artifact created by that workflow run. It does not create a second build after approval.

The production concurrency group also queues deploy jobs instead of running them at the same time.

I disable the package manager cache in the privileged deploy job. The job can install its deployment tool from the lockfile without restoring a cache created in a lower-trust context.

The deploy token is available only to the final command. If the hosting provider supports OIDC, I would replace that stored token with short-lived authentication.

The exact deploy command depends on the host. My GitHub Actions deploy workflow generator creates smaller starting workflows for Cloudflare Pages, Fly.io, a VPS, and npm.

Debug a failing workflow

Start with the first failing step. Later failures are often consequences.

These are the problems I check first:

  • a file exists locally but was never committed
  • the job uses the wrong working directory
  • the lockfile does not match package.json
  • the runtime version differs from local development
  • a secret or token permission is missing
  • a filename differs only by letter case and fails on Linux
  • a command assumes Zsh but the runner uses Bash
  • one job expects files created on another runner
  • a forked pull request expects a secret it cannot receive

Give every important step a clear name:

- name: Run unit tests
  run: npm test

The workflow log becomes much easier to scan.

You can inspect safe context values:

- name: Show run context
  run: echo "event=$EVENT_NAME ref=$REF_NAME sha=$COMMIT_SHA"
  env:
    EVENT_NAME: ${{ github.event_name }}
    REF_NAME: ${{ github.ref_name }}
    COMMIT_SHA: ${{ github.sha }}

Do not dump the complete github context into logs. It can contain values you did not intend to expose.

You can also write a readable summary:

- run: echo '## Build completed' >> "$GITHUB_STEP_SUMMARY"

For a complex shell block, move it into a project script and run it locally. Debugging a normal script is easier than repeatedly pushing YAML changes.

Keep workflows fast and affordable

The fastest workflow is the work you do not run.

Start with these changes:

  • narrow triggers to meaningful branches and paths
  • cancel obsolete CI runs
  • remove matrix combinations you do not support
  • cache rebuildable dependencies
  • keep artifacts only as long as needed
  • run independent jobs in parallel
  • combine tiny commands when runner startup dominates their runtime

Do not add a cache without measuring it. Uploading and restoring a large cache can take longer than reinstalling the dependency.

Self-hosted runners are not automatically cheaper. Include machine maintenance, patching, isolation, scaling, and the risk of persistent credentials.

I built a CI cost calculator to estimate how run frequency, duration, operating system, and team size change monthly usage.

How I use GitHub Actions

I use GitHub Actions as an independent check around normal project commands.

My ideal CI workflow is boring. It checks out the repository, selects the runtime, installs from a lockfile, runs tests, and builds the project.

I keep deployment authority separate from pull request checks. The deployment path gets its own condition, environment, concurrency rule, and minimum permissions.

On this site, Cloudflare Pages handles normal deployments when I push to the repository. I do not need GitHub Actions to rebuild and upload the site on every commit.

I do use a scheduled GitHub workflow as a backup for future-dated posts. It calls a Cloudflare deploy hook in the morning. A scheduled Cloudflare Worker is the punctual publisher because GitHub cron can run late.

I explain that real setup in How I auto-publish scheduled posts with a Cloudflare Worker cron.

This is the boundary I like: use the platform’s native Git deployment when it already solves the problem, and add Actions for checks or automation the platform does not provide.

When I would not use GitHub Actions

GitHub Actions is a poor fit for some jobs.

I would not use it for work that must start at an exact minute. Scheduled workflows are best effort.

I would not use it as a general application server or a long-running background worker. Jobs are temporary and have execution limits.

I would think twice before moving a high-volume, compute-heavy build into Actions. A specialized CI platform or build service may be faster and easier to control.

I would also avoid self-hosting just to save a few minutes. A persistent runner is infrastructure and a security boundary, not a free speed button.

Finally, I would not add a custom deployment workflow when the hosting platform’s repository integration already gives me previews, production deploys, logs, and rollback with less code.

GitHub Actions is most useful when repository events, versioned automation, and GitHub’s permission model fit the work.

The YAML is the visible part. The real design is the event, job graph, data flow, permissions, and recovery path behind it.

Tagged: Git · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about git: