# CI workflows

> GitHub Actions workflows that automate PR checks, label management, and other CI/CD processes.

---

LLMS index: [llms.txt](/llms.txt)

---

For workflows and (most of) their helper scripts, see the `workflow` and
`scripts` folder under [.github][]

## PR approval labels {#pr-approval-labels}

The following workflows work together to automatically manage approval-related
labels on pull requests:

| Workflow file                      | Trigger                               | Privileges                                   |
| ---------------------------------- | ------------------------------------- | -------------------------------------------- |
| [`pr-review-trigger.yml`][trigger] | `pull_request_review`                 | Minimal (no secrets)                         |
| [`pr-approval-labels.yml`][labels] | `pull_request_target`, `workflow_run` | App token for label edits and org/team reads |
| [`blog-publish-labels.yml`][blog]  | `schedule` (daily 7 AM UTC)           | App token + `SLACK_WEBHOOK_URL` secret       |

[trigger]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/pr-review-trigger.yml
[labels]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/label-manager.yml
[blog]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/blog-publish-labels.yml

### Labels managed

- **`missing:docs-approval`** — added when approval from the
  [`docs-approvers`][docs-approvers] team is pending; removed once a
  docs-approver approves.
- **`missing:sig-approval`** — added when approval from a SIG team is pending
  (determined by files changed and [`.github/component-owners.yml`][owners]);
  removed once a SIG member approves or when no SIG component is touched.
- **`ready-to-be-merged`** — added when all required approvals are present;
  removed otherwise. For PRs carrying any label in
  [`PUBLISH_DATE_LABELS`](#publish-date-gating) (currently: `blog`), this label
  is also gated on the publish date found in changed files.

[docs-approvers]: https://github.com/orgs/open-telemetry/teams/docs-approvers
[owners]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/component-owners.yml

### Publish date gating {#publish-date-gating}

The script scans each changed file for a line beginning with `date:` (typically
from the front matter in Markdown content). If it finds a date in the future,
the `ready-to-be-merged` label is withheld until that date arrives (UTC). This
helps prevent content from being merged before its scheduled publication date.

The check applies to PRs carrying any label listed in the `PUBLISH_DATE_LABELS`
environment variable, set in each workflow YAML (currently: `blog`). Adding a
label extends the check to other PR types.

If a PR contains multiple files with different dates, the label is gated on the
latest date — all content must be ready before merging.

#### Script operating modes

The [`pr-approval-labels.sh`][script] script processes a single PR (set via the
`PR` environment variable). It is called by `pr-approval-labels.yml` on PR
events and by [`blog-publish-check.sh`][batch-script] in batch mode.

[script]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/scripts/pr-approval-labels.sh
[batch-script]:
  https://github.com/open-telemetry/opentelemetry.io/blob/248cc6f/.github/scripts/blog-publish-check.sh

The [`blog-publish-check.sh`][batch-script] script handles batch iteration: it
queries all open PRs carrying any `PUBLISH_DATE_LABELS` label and calls
`pr-approval-labels.sh` for each one. Used by the
[`blog-publish-labels.yml`](#blog-publish-labels) `schedule` trigger (daily at 7
AM UTC), so a PR whose publish date arrives overnight receives
`ready-to-be-merged` automatically without requiring a new commit.

### Why two workflows?

GitHub's `pull_request_review` event has no `_target` variant. This means a
workflow triggered by a review on a **fork PR** runs in the fork's context and
cannot access the base repository's secrets.

To work around this limitation, the system uses a
[`workflow_run` chaining pattern](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run):

1. **`pr-review-trigger`** runs on every review submission/dismissal. It saves
   the PR number as an artifact and exits — no secrets needed.
2. **`pr-approval-labels`** is triggered by `workflow_run` (when the trigger
   workflow completes). It runs in the base repository context with full access
   to the GitHub App token, downloads the artifact, and updates labels.

For content changes (`opened`, `reopened`, `synchronize`), the
`pr-approval-labels` workflow is triggered directly via `pull_request_target`.

```mermaid
sequenceDiagram
    participant R as Reviewer
    participant GH as GitHub
    participant T as pr-review-trigger
    participant L as pr-approval-labels

    R->>GH: Submits review (approve/request changes/dismiss)

    Note over GH: pull_request_review event

    GH->>T: Trigger (fork context, no secrets)
    T->>T: Save PR number as artifact
    T->>GH: Upload artifact, workflow completes

    Note over GH: workflow_run event (completed)

    GH->>L: Trigger (base repository context, with secrets)
    L->>L: Download PR number artifact
    L->>L: Run pr-approval-labels.sh
    L->>GH: Add/remove labels
```

```mermaid
sequenceDiagram
    participant A as Author
    participant GH as GitHub
    participant L as pr-approval-labels

    A->>GH: Opens/updates PR

    Note over GH: pull_request_target event

    GH->>L: Trigger directly (base repo context, with secrets)
    L->>L: Run pr-approval-labels.sh
    L->>GH: Add/remove labels
```

### Security model

- **`pr-review-trigger`**: intentionally minimal — no secrets, no privileged
  permissions. Ignores `review.state == "commented"` since comments don't affect
  approvals.
- **`pr-approval-labels`**: runs with a GitHub App token
  (`OTELBOT_DOCS_CLIENT_ID` / `OTELBOT_DOCS_PRIVATE_KEY`) that has permissions
  to read org/team membership and edit PR labels. Uses `pull_request_target` and
  `workflow_run` to ensure it always executes in the trusted base repository
  context.
- **`blog-publish-labels`**: runs on a schedule with a GitHub App token and the
  `SLACK_WEBHOOK_URL` secret. Always executes in the trusted base repository
  context (schedule events have no fork variant).

## Blog publish labels {#blog-publish-labels}

The [`blog-publish-labels.yml`][blog] workflow runs daily at 7 AM UTC. It
executes [`blog-publish-check.sh`][batch-script], which iterates over all open
PRs with `blog` label and calls `pr-approval-labels.sh` for each one. When
`ready-to-be-merged` is newly applied to any of them, a Slack notification is
posted. You can also trigger it manually via `workflow_dispatch` with the
`force_notify` input to send a test Slack notification. When `force_notify` is
`true`, the labeling step is skipped entirely (dry run) — only the test Slack
payload is sent.

| Workflow file                     | Trigger                                                                           | Secrets required                                |
| --------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------- |
| [`blog-publish-labels.yml`][blog] | `schedule` (daily 7 AM UTC), `workflow_dispatch` (manual test via `force_notify`) | `OTELBOT_DOCS_PRIVATE_KEY`, `SLACK_WEBHOOK_URL` |

The Slack notification fires only when the label transitions from absent to
present on that run — repeated daily runs for an already-labeled PR do not
re-notify. When triggering the workflow manually, set `force_notify` to `true`
to send a one-off test notification (no labels are applied) so you can verify
the Slack formatting.

### Slack webhook setup {#slack-webhook-setup}

The workflow uses a **Slack Workflow Builder webhook trigger**, which allows
non-engineers to own the message format without touching workflow code.

**Create the webhook:**

1. In Slack: **Tools → Workflow Builder → New Workflow → Start from scratch**
2. Choose trigger: **Webhook**
3. Declare one variable — name: `pr_list`, type: **Text**
4. Add a step: **Send a message** to the desired channel, with body:

   ```text
   :newspaper: *Blog posts ready to publish*

   The following PRs have reached their publish date and all required
   approvals — they are ready to be merged:

   {{pr_list}}

   Have a great day! :sunny:
   ```

   Then click **Add button** and configure:
   - **Label**: `Review and merge`
   - **Color**: Primary (green)
   - **Action**: Open a link
   - **URL**:
     `https://github.com/open-telemetry/opentelemetry.io/issues?q=is%3Apr+state%3Aopen+label%3Ablog+label%3Aready-to-be-merged`

5. **Publish** the workflow and copy the webhook URL
6. Add it to the repository: **Settings → Secrets and variables → Actions → New
   repository secret**, name: `SLACK_WEBHOOK_URL`

**Payload sent by the workflow:**

```json
{
  "pr_list": "• #123: Add blog post: OTel 1.0 — https://github.com/.../pull/123\n• #456: Announce: new SIG — https://github.com/.../pull/456"
}
```

Each PR is a bulleted line with its title and URL. Slack auto-links bare URLs.
Multiple PRs labeled on the same day are batched into a single message — one
webhook call regardless of how many PRs are ready.

```mermaid
sequenceDiagram
    participant GH as GitHub
    participant W as blog-publish-labels
    participant B as blog-publish-check.sh
    participant L as pr-approval-labels.sh
    participant S as Slack

    Note over GH: schedule event (daily, 7 AM UTC)

    GH->>W: Trigger (base repository context, with secrets)
    W->>B: Run blog-publish-check.sh
    B->>GH: Query open PRs with PUBLISH_DATE_LABELS labels
    GH-->>B: List of PRs
    loop Each PR
        B->>L: Run pr-approval-labels.sh (PR=number)
        L->>GH: Add/remove labels
    end
    alt Any PR newly labeled ready-to-be-merged
        W->>S: POST Slack notification with PR links
    end
```

## PR fix directives {#pr-fix-directives}

The [`pr-actions.yml`][pr-actions] workflow lets contributors run selected `fix`
scripts by commenting on a PR:

- **`/fix`** runs `npm run fix`.
- **`/fix:<name>`** runs `npm run fix:<name>` (for example, `/fix:format`).
- **`/fix:all`** is mapped to `/fix` since the command semantics changed
  ([#9291][]).
- **`/fix:ALL`** is mapped to `fix:all` so that maintainers can run `fix:all`.

The directive must be the first line of the comment; any following lines are
ignored, so you can add an explanation after it. The workflow itself triggers on
any comment whose body starts with `/fix` (so for example `/fixup` enters the
pipeline and gets invalid-directive feedback, while a comment starting with a
space, or with `/fix` only on a later line, does not trigger the workflow at
all).

[#9291]: https://github.com/open-telemetry/opentelemetry.io/pull/9291

It runs as a four-stage pipeline:

1. **`ack`** (trusted): as soon as a directive is received, replies with a 🔄
   in-progress comment that links to the directive comment and to the run.
2. **`generate-patch`** (untrusted): checks out the PR branch, runs the fix
   command, and uploads a patch artifact (`site.patch`), up to 1024 KB.
3. **`apply-patch`** (trusted): calls the [`reusable-apply-patch.yml`][]
   workflow — resolved from the default branch, never from the PR — which
   applies the patch with a GitHub App token and pushes a commit to the PR
   branch. Skipped when the command produced no changes.
4. **`report`** (trusted): replaces the acknowledgement with the final outcome
   when possible, or posts a new outcome comment when no acknowledgement exists,
   such as for closed PRs. Each directive thus normally maps to a single comment
   that links back to the directive and to the run that produced it. This covers
   every directive that triggers the workflow, including invalid directives
   (such as `/fixup` or `/fix please`), no-op runs, and failures that happen
   before any patch is produced.

Directives only run against open PRs (draft PRs included): on a closed or merged
PR the fix command never runs and the report job explains why. The PR state
comes from the trigger payload, so no runner is spent on the fix itself.

The pipeline only runs in the canonical `open-telemetry` repository, where the
bot app credentials exist. Fork PRs work normally — `issue_comment` events fire
in the base repository — but the workflow skips itself inside forks.

Directives follow latest-wins semantics: a new `/fix` comment on a PR cancels
that PR's in-flight run (which still reports a ⚠️ outcome), since concurrent fix
runs on the same branch serve no purpose — the second push would fail anyway
once the branch has moved.

The directive parser lives in [scripts/gh/pr-fix/][], patch generation is the
[npm-script-patch][] action, and the acknowledgement and outcome comments are
composed by [scripts/gh/patch-report/][]; all are unit tested via
`npm run test:local-tools`.

[pr-actions]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/pr-actions.yml
[`reusable-apply-patch.yml`]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/reusable-apply-patch.yml
[npm-script-patch]:
  https://github.com/open-telemetry/opentelemetry.io/tree/main/.github/actions/npm-script-patch
[scripts/gh/pr-fix/]:
  https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/pr-fix
[scripts/gh/patch-report/]:
  https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/patch-report

## Housekeeping {#housekeeping}

The [`housekeeping.yml`][housekeeping] workflow runs an approved fix command —
[`fix-and-test:all`](../npm-scripts/) by default, or an npm script given via
manual (maintainer-only) dispatch — daily at 21:37 UTC, about 12 hours after the
other daily automation jobs, and publishes any resulting changes as a PR. It is
the second caller of the reusable patch actions, and the scheduled-maintenance
flow that motivated [#6592][].

It runs as a three-stage pipeline:

1. **`generate-patch`**: runs the housekeeping command via the
   [npm-script-patch][] action and uploads the changes as a patch artifact.
   Unlike the `/fix` pipeline, the whole run is trusted: the schedule and
   dispatch triggers only ever execute default-branch code. A failing command
   fails the job, but any fixes it produced are still published, with a
   partial-results warning in the PR body.
2. **`publish-patch`**: calls the [`reusable-patch-pr.yml`][] workflow — the
   sibling of [`reusable-apply-patch.yml`][] for callers without a PR context —
   which force-pushes the patch to the stable `otelbot/housekeeping` branch,
   recreated from `main` on every run, and opens a PR for it unless one is
   already open. There is thus at most one housekeeping PR at a time, always
   carrying the latest results. Any commits pushed to the branch — manual or via
   `/fix` — are clobbered by the next run, so merge the PR promptly if you push
   commits to it. Skipped when the command produced no changes, leaving any open
   housekeeping PR as is. Auto-merge is safe to enable on housekeeping PRs
   provided that stale approvals are dismissed when commits are pushed: required
   reviews then remain the control over the machine- and internet-derived
   content, even across force-pushes.
3. **`report-failure`**: files a tracking issue on failure, via
   [workflow failure reporting](#workflow-failure-reporting); when fixes were
   published, the issue links to the housekeeping PR.

> [!NOTE]
>
> The [`refcache-refresh.yml`][] workflow also runs daily and touches
> `.lycheecache`, so the two bot PRs can conflict depending on merge order.
> Conflicts self-heal, since both branches sync from `main` on each run.
> Migrating refcache-refresh onto the reusable patch actions — eliminating such
> conflicts by construction — is tracked in the [project plan][].

[#6592]: https://github.com/open-telemetry/opentelemetry.io/issues/6592
[housekeeping]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/housekeeping.yml
[project plan]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/projects/2026/pr-fix-reusable-actions.plan.md
[`refcache-refresh.yml`]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/refcache-refresh.yml
[`reusable-patch-pr.yml`]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/reusable-patch-pr.yml

## Locale auto-merge

The [locale-auto-merge.yml][] workflow lets a locale's maintainers enable
[GitHub auto-merge][] on a locale-only PR through an `/auto-merge` (or
`/auto-merge:enable` / `/auto-merge:disable`) comment directive — for placement
rules, see the helper [README][locale-auto-merge-script]. It runs as the DOCS
bot, which holds the privileges needed to flip the "merge when ready" switch
under branch protection; CODEOWNERS and required checks remain the hard merge
gate.

The thin workflow delegates to the testable helper in
[scripts/gh/locale-auto-merge/][locale-auto-merge-script], which enforces two
guards before acting: every changed file must be locale-owned, and the commenter
must be a member of the `docs-<loc>-maintainers` team for every locale the PR
touches. The helper's eligibility and authorization rules (and how to dry-run
them locally) are documented in its [README][locale-auto-merge-script]; its unit
and integration tests run with `npm run test:local-tools`. Contributor-facing
usage lives in the [localization guide][localization-auto-merge].

[GitHub auto-merge]:
  https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request
[locale-auto-merge.yml]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/locale-auto-merge.yml
[locale-auto-merge-script]:
  https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/locale-auto-merge
[localization-auto-merge]: /docs/contributing/localization/#auto-merge

## Spec integration branches {#spec-integration-branches}

The scheduled [specs-integration.yml][] workflow owns the site's update cycle
for the upstream spec repositories (which `auto-update-versions.yml` therefore
excludes). It runs one matrix job per upstream repository: between releases,
each job tracks unreleased upstream changes through a draft PR ("integration
branch"); once upstream releases, it finalizes that branch and PR into the
release PR.

| Matrix job | Upstream repository           | Branch slug |
| ---------- | ----------------------------- | ----------- |
| `otel`     | `opentelemetry-specification` | `spec`      |
| `semconv`  | `semantic-conventions`        | `semconv`   |

[specs-integration.yml]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/specs-integration.yml

Each job delegates the "pick the mode, version and branch" step to a shared
helper, [scripts/gh/specs/pick-branch.mjs][]. The helper:

- Selects the run's `MODE`: `dev` while the version pinned on main is the latest
  upstream release, `release` once a newer release exists.
- Writes `MODE`, `VERSION` and `BRANCH` to `$GITHUB_ENV` for downstream steps.
- Opens a tracking issue (label `<slug>-integration-warning`, deduplicated) when
  it detects problems such as multiple stale integration branches.

[scripts/gh/specs/pick-branch.mjs]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/pick-branch.mjs

The final step, [scripts/gh/specs/create-or-finalize-pr.mjs][], creates or
finalizes the PR as `MODE` calls for: in dev mode it opens the draft integration
PR if missing; in release mode it creates or finalizes the release PR. It only
rewrites PR text that the automation itself wrote: a maintainer-edited title or
body is left alone.

[scripts/gh/specs/create-or-finalize-pr.mjs]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/scripts/gh/specs/create-or-finalize-pr.mjs

### Run modes

Both helpers auto-select between dry-run and write mode and print a `[mode]`
banner explaining their choice:

| Context               | Default behavior | Override            |
| --------------------- | ---------------- | ------------------- |
| GitHub Actions        | write            | pass `--dry-run`    |
| Local (anywhere else) | dry-run          | pass `--no-dry-run` |

Locally, dry-run still runs all read-only `git`/`gh` commands (so the issue
deduplication check executes), but skips writes. With `--no-dry-run` the helpers
use your local `gh` credentials; if `GITHUB_ENV` is unset, pick-branch prints
`MODE`/`VERSION`/`BRANCH` to stdout only — export these to feed a local
create-or-finalize-pr run. Try it:

```sh
scripts/gh/specs/pick-branch.mjs --spec otel
scripts/gh/specs/pick-branch.mjs --spec semconv --no-dry-run
scripts/gh/specs/create-or-finalize-pr.mjs --help
```

Pure logic lives in each helper's `index.mjs` and is covered by `*.test.mjs`
files in the same folder (`npm run test:local-tools` to run them).

## Workflow failure reporting {#workflow-failure-reporting}

[`reusable-report-failure.yml`][report-failure] opens (or comments on) a
tracking issue when a caller workflow fails. How to wire it up, optional inputs,
and caller context behavior are documented in the workflow file header; issue
logic lives in [scripts/gh/report-failure/][report-failure-script]
(`npm run test:local-tools`).

[report-failure]:
  https://github.com/open-telemetry/opentelemetry.io/blob/main/.github/workflows/reusable-report-failure.yml
[report-failure-script]:
  https://github.com/open-telemetry/opentelemetry.io/tree/main/scripts/gh/report-failure

## Other workflows

The repository includes several other workflows:

| Workflow                   | Purpose                                                                                      |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| `check-links.yml`          | Site build and [link checking][] with Lychee                                                 |
| `check-text.yml`           | Textlint terminology checks                                                                  |
| `check-i18n.yml`           | Localization front matter validation                                                         |
| `check-spelling.yml`       | Spell checking                                                                               |
| `test.yml`                 | Test (excludes `test:base`)                                                                  |
| `auto-update-registry.yml` | Auto-update registry package versions                                                        |
| `auto-update-versions.yml` | Auto-update OTel component versions (except [spec repositories](#spec-integration-branches)) |
| `build-dev.yml`            | Development build and preview                                                                |
| `lint-scripts.yml`         | ShellCheck linting for `.github/scripts/`                                                    |
| `label-manager.yml`        | PR labels (component labels & approval flow)                                                 |
| `component-owners.yml`     | Assign reviewers based on component ownership                                                |

<!-- prettier-ignore-start -->
[link checking]: ../link-checking/
[.github]: https://github.com/open-telemetry/opentelemetry.io/tree/main/.github
<!-- prettier-ignore-end -->
