> ## Documentation Index
> Fetch the complete documentation index at: https://lib.findy.co.jp/llms.txt
> Use this file to discover all available pages before exploring further.

# CI/CD

> What a CI/CD pipeline provides, how to keep CI fast, automate releases, notify results, and treat deployment frequency as a metric.

## Overview

CI/CD is the practice of automating the steps between a code change and its
delivery to users. Continuous integration (CI) builds and tests every change
automatically; continuous delivery (CD) automates the path from a merged change
to a deployed one.

This page explains what a CI/CD pipeline provides, how to
keep CI fast, how to automate releases, how to notify pipeline results, and how
to treat metrics such as deployment frequency. Examples use GitHub Actions, but the principles apply to
any CI service.

## What a CI/CD pipeline provides

With a pipeline in place, the routine steps around every change run without
manual intervention:

<CardGroup cols={2}>
  <Card title="Automated test gates" icon="vial">
    Tests run on every pull request, and merging is blocked until they pass. A
    broken change is caught in minutes, not after release.
  </Card>

  <Card title="Automated deploys" icon="rocket">
    Each environment deploys on a defined trigger — for example, staging when a
    pull request targets a release branch, production when it merges.
  </Card>
</CardGroup>

These two capabilities are the baseline. The following sections cover the
properties that determine how much value the pipeline delivers: speed, release
automation, and feedback that reaches developers.

## Keep CI fast

The duration of CI is a cost paid by every change. As a guideline, keep the
pipeline that gates pull requests within about 5–10 minutes.

### Cache dependencies

Installing dependencies from scratch on every run is pure overhead. Official
setup actions can cache them keyed on the lockfile, so the cache is rebuilt
only when dependencies actually change.

<CodeGroup>
  ```yaml Node.js theme={null}
  name: Test
  on: [push, pull_request]

  jobs:
    test:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v7
        - uses: actions/setup-node@v7
          with:
            node-version: 22
            cache: "npm"
        - run: npm ci
        - run: npm test
  ```

  ```yaml Ruby theme={null}
  name: Test
  on: [push, pull_request]

  jobs:
    test:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v7
        - uses: ruby/setup-ruby@v1
          with:
            ruby-version: "3.3"
            bundler-cache: true
        - run: bundle exec rspec
  ```
</CodeGroup>

The same idea extends beyond package installs: any expensive, reproducible
setup — a prepared database schema, a build artifact — is a caching candidate.

### Skip runs that cannot affect the result

A change that does not affect runtime behavior does not need the full test
suite. `paths-ignore` skips the workflow when only the listed files changed:

```yaml theme={null}
on:
  pull_request:
    paths-ignore:
      - "**.md"
      - "docs/**"
```

<Note>
  If the skipped workflow is a required status check, the check stays pending
  and blocks the merge. In that case, keep the workflow running and skip only
  the expensive jobs inside it, or adjust the required checks.
</Note>

### Parallelize tests

As the test suite grows, total execution time grows with it. Parallelization
splits the suite across jobs that run concurrently.

The `matrix` strategy runs the same job once per listed value; each job uses
its value to execute only its own slice of the suite:

<CodeGroup>
  ```yaml Node.js theme={null}
  jobs:
    test:
      runs-on: ubuntu-latest
      strategy:
        matrix:
          shard: [1, 2, 3]
      steps:
        - uses: actions/checkout@v7
        - uses: actions/setup-node@v7
          with:
            node-version: 22
            cache: "npm"
        - run: npm ci
        - run: npx jest --shard=${{ matrix.shard }}/${{ strategy.job-total }}
  ```

  ```yaml Rails theme={null}
  jobs:
    test:
      runs-on: ubuntu-latest
      strategy:
        matrix:
          ci_node_index: [0, 1, 2]
      env:
        RAILS_ENV: test
      steps:
        - uses: actions/checkout@v7
        - uses: ruby/setup-ruby@v1
          with:
            ruby-version: "3.3"
            bundler-cache: true
        # database setup steps omitted
        - name: Run tests for this shard
          env:
            CI_NODE_TOTAL: ${{ strategy.job-total }}
            CI_NODE_INDEX: ${{ matrix.ci_node_index }}
          run: |
            TEST_FILES=$(find spec -name "*_spec.rb" | sort | awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX")
            if [ -n "$TEST_FILES" ]; then
              bundle exec rspec $TEST_FILES
            fi
  ```
</CodeGroup>

The wall-clock time of a parallel run is the time of its slowest shard.

If the runner has no shard option, or its default split leaves the shards unbalanced,
split the file list yourself and distribute it by measured execution time — not
by file count — so the shards finish together. Increase the shard count as the
suite grows.

### Scale up runners

Once caching and parallelization are in place, larger runners with more CPU
cores and memory shorten builds and increase in-job parallelism.

Cost per unit of run time rises with runner size, but a shorter run consumes
fewer minutes, so the billed amount can stay flat or even drop while waiting
time falls.

<Tip>
  Treat CI optimization as a measurement loop. Per-job timings from the CI logs
  show where the time actually goes; find the largest slice, change one thing,
  and verify the delta instead of optimizing on a hunch.
</Tip>

## Automate releases

A manual release — writing the changelog and running deploy steps in the right
order — is where mistakes and delays creep in. The whole sequence can be
automated:

<Steps>
  <Step title="Keep a traceable history">
    Release notes are assembled from the history of merged pull requests and
    their commits. See
    [Commit messages](/development/pull-request#commit-messages) for how to
    write messages that read well later.
  </Step>

  <Step title="Generate the release notes automatically">
    Assemble the list of pull requests merged since the last release as the
    release notes, so nobody hand-edits a changelog.
  </Step>

  <Step title="Reduce a deploy to merging a release pull request">
    One workflow generates a release pull request. A separate workflow,
    triggered by the push to `main` when it merges, runs the deploy. A
    production release becomes a review and a merge instead of a runbook.
  </Step>
</Steps>

The relationship between the branches is as follows:

```mermaid theme={null}
gitGraph
  commit id: "init"
  branch develop
  checkout develop
  branch feature/a
  checkout feature/a
  commit id: "feat-a"
  checkout develop
  merge feature/a
  branch feature/b
  checkout feature/b
  commit id: "feat-b"
  checkout develop
  merge feature/b
  branch release-YYYY-MM-DD
  checkout release-YYYY-MM-DD
  commit id: "release"
  checkout main
  merge release-YYYY-MM-DD
```

For example, the following workflow implements this flow for a branch model
where `develop` collects merged work and `main` mirrors production — a
simplified form of git flow.

Triggered manually, it cuts a dated release branch from `develop`, assembles the list of
pull requests merged since the last release into the body, and opens a release
pull request into `main`:

```yaml theme={null}
name: Create Release Pull Request
on: [workflow_dispatch]

concurrency:
  group: create-release-pr
  cancel-in-progress: true

jobs:
  create-release-pr:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          # use a PAT (or a GitHub App token)
          token: ${{ secrets.RELEASE_PAT }}

      - name: Ensure no pull request to main is open
        env:
          GH_TOKEN: ${{ secrets.RELEASE_PAT }}
        run: |
          test "$(gh pr list --base main --json number --jq length)" = "0"

      - name: Create a release branch from develop
        id: branch
        run: |
          branch="release-$(date +'%Y-%m-%d-%H%M%S')"
          git checkout -b "$branch" origin/develop
          git push -u origin "$branch"
          echo "name=$branch" >> "$GITHUB_OUTPUT"

      - name: List pull requests merged since the last release
        env:
          BRANCH: ${{ steps.branch.outputs.name }}
        run: |
          {
            echo "## Changes"
            git log --merges --pretty=format:'%s' "origin/main..${BRANCH}" \
              | grep -oE '#[0-9]+' | sed 's/^/- /'
          } > body.md

      - name: Open the release pull request
        env:
          GH_TOKEN: ${{ secrets.RELEASE_PAT }}
          BRANCH: ${{ steps.branch.outputs.name }}
          ACTOR: ${{ github.actor }}
        run: |
          gh pr create --base main --head "$BRANCH" \
            --title "Release $(date +'%Y-%m-%d')" \
            --body-file body.md \
            --assignee "$ACTOR"
```

<Note>
  The pull request list is extracted from merge commits, so this workflow
  assumes pull requests are merged into `develop` with "Create a merge
  commit". With squash merges, replace the extraction with a query such as
  `gh pr list --state merged`.
</Note>

A separate workflow triggers on the push to `main` — the merge of the release
pull request — and runs the production deploy:

```yaml theme={null}
name: Deploy
on:
  push:
    branches: [main]

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    timeout-minutes: 30
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: "npm"
      - run: npm ci
      - run: npm run build

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
          aws-region: ap-northeast-1

      - name: Deploy
        run: npm run deploy # platform-specific deploy command
```

`concurrency` prevents overlapping deploys when pushes land in quick
succession. The `environment` designation applies GitHub's protection rules and
environment-scoped secrets to production.

The same approach removes recurring friction elsewhere in the workflow:
assigning the author to their own pull request, applying labels based on branch
name or changed paths, and extracting checked items from the pull request
template as a QA hand-off at release time.

## Notify pipeline results

A pipeline shortens the feedback loop only if its results reach the people who
act on them. Two channels cover most cases:

* **Status checks on the pull request.** The CI service reports each job's
  result on the pull request itself. This is built in and covers feedback while
  a change is under review.
* **Chat notifications.** Events that happen outside a pull request — a
  production deploy finishing, a scheduled workflow failing — need a push
  channel such as Slack.

For example, a step at the end of a deploy job can post the result to Slack
whether the job succeeds or fails:

```yaml theme={null}
steps:
  # deploy steps omitted
  - name: Notify Slack of the result
    if: always()
    uses: slackapi/slack-github-action@v3
    with:
      webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
      webhook-type: incoming-webhook
      payload: |
        text: "Deploy ${{ job.status }}: ${{ github.workflow }} (${{ github.ref_name }})"
```

<Tip>
  Notify successes as well as failures. If only failures produce a message,
  silence is ambiguous — it can mean the deploy succeeded, or that the
  notification path itself is broken. A success message confirms that the
  pipeline and the notification channel are both working.
</Tip>

## Deployment frequency as a result, not a target

Deployment frequency is a widely used productivity metric, and a high value is
the *result* of working practices: automated pipelines, small well-scoped
changes, and a test suite that keeps the risk of each change low.

Optimizing the number itself inverts the causality and degrades the outcome it
is meant to measure:

* **Splitting unchanged work into more deploys** adds operational overhead and
  raises the change failure rate without delivering more value.
* **Lowering deploy frequency to improve the failure rate** batches more
  changes into each deploy, which makes failures more likely and their causes
  harder to isolate.

To raise deployment frequency sustainably, improve the inputs instead: keep CI
fast, automate the release path as described above, and keep changes small and
independently shippable (see [Task breakdown](/development/task-breakdown) and
[Testing](/development/testing)).
