CI workflows
All workflow files live under
.github/workflows/.
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 | pull_request_review | Minimal (no secrets) |
pr-approval-labels.yml | pull_request_target, workflow_run | App token for label edits and org/team reads |
blog-publish-labels.yml | schedule (daily 7 AM UTC) | App token + SLACK_WEBHOOK_URL secret |
Labels managed
missing:docs-approval— added when approval from thedocs-approversteam 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); 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 inPUBLISH_DATE_LABELS(currently:blog), this label is also gated on the publish date found in changed files.
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 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 in batch mode.
The blog-publish-check.sh 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 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:
pr-review-triggerruns on every review submission/dismissal. It saves the PR number as an artifact and exits — no secrets needed.pr-approval-labelsis triggered byworkflow_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.
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 labelssequenceDiagram
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 labelsSecurity model
pr-review-trigger: intentionally minimal — no secrets, no privileged permissions. Ignoresreview.state == "commented"since comments don’t affect approvals.pr-approval-labels: runs with a GitHub App token (OTELBOT_DOCS_APP_ID/OTELBOT_DOCS_PRIVATE_KEY) that has permissions to read org/team membership and edit PR labels. Usespull_request_targetandworkflow_runto ensure it always executes in the trusted base repository context.blog-publish-labels: runs on a schedule with a GitHub App token and theSLACK_WEBHOOK_URLsecret. Always executes in the trusted base repository context (schedule events have no fork variant).
Blog publish labels
The blog-publish-labels.yml workflow runs daily at 7 AM UTC. It
executes blog-publish-check.sh, 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 | 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
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:
In Slack: Tools → Workflow Builder → New Workflow → Start from scratch
Choose trigger: Webhook
Declare one variable — name:
pr_list, type: TextAdd a step: Send a message to the desired channel, with body:
: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
- Label:
Publish the workflow and copy the webhook URL
Add it to the repository: Settings → Secrets and variables → Actions → New repository secret, name:
SLACK_WEBHOOK_URL
Payload sent by the workflow:
{
"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.
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
endPR fix directives
The pr-actions.yml workflow lets contributors run selected fix
scripts by commenting on a PR:
/fixrunsnpm run fix./fix:<name>runsnpm run fix:<name>(for example,/fix:format)./fix:allis mapped to/fixsince the command semantics changed (#9291)./fix:ALLis mapped tofix:allso that maintainers can runfix: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).
It runs as a four-stage pipeline:
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.generate-patch(untrusted): checks out the PR branch, runs the fix command, prunes the link refcache, and uploads a patch artifact (site.patch), up to 1024 KB.apply-patch(trusted): calls thereusable-apply-patch.ymlworkflow — 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.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/fixupor/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.
Housekeeping
The housekeeping.yml workflow runs an approved fix command —
fix-and-test:all 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:
generate-patch: runs the housekeeping command via the npm-script-patch action and uploads the changes as a patch artifact. Unlike the/fixpipeline, 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.publish-patch: calls thereusable-patch-pr.ymlworkflow — the sibling ofreusable-apply-patch.ymlfor callers without a PR context — which force-pushes the patch to the stableotelbot/housekeepingbranch, recreated frommainon 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.report-failure: files a tracking issue on failure, via workflow failure reporting; when fixes were published, the issue links to the housekeeping PR.
The refcache-refresh.yml workflow also runs daily and touches
refcache.json, 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.
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. 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/, 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; its unit
and integration tests run with npm run test:local-tools. Contributor-facing
usage lives in the localization guide.
Spec integration branches
Two scheduled workflows track unreleased changes from upstream spec repositories and keep a draft PR (“integration branch”) current with the next development version:
| Workflow file | Upstream repository | Branch slug |
|---|---|---|
| update-spec-integration-branch.yml | opentelemetry-specification | spec |
| update-semconv-integration-branch.yml | semantic-conventions | semconv |
Both workflows delegate the “pick the next version + branch” step to a shared Node helper, scripts/gh/specs/pick-branch/cli.mjs. The helper:
- Reuses an existing
otelbot/<slug>-integration-vX.Y.Z-devbranch when one exists and the version has not yet been released; otherwise bumps the latest release tag’s minor version. - Writes
VERSIONandBRANCHto$GITHUB_ENVfor downstream steps. - Opens a tracking issue (label
<slug>-integration-warning, deduplicated) when it detects problems such as multiple stale integration branches.
Run modes
The helper auto-selects between dry-run and write mode and prints a [mode]
banner explaining its 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 helper
uses your local gh credentials; if GITHUB_ENV is unset, VERSION/BRANCH
are printed to stdout only. Try it:
node scripts/gh/specs/pick-branch/cli.mjs --spec=otel
node scripts/gh/specs/pick-branch/cli.mjs --spec=semconv --no-dry-run
node scripts/gh/specs/pick-branch/cli.mjs --help
Pure logic and CLI argument parsing live in index.mjs and are covered by
*.test.mjs files in the same folder (npm run test:local-tools to run them).
Workflow failure reporting
reusable-report-failure.yml 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/
(npm run test:local-tools).
Other workflows
The repository includes several other workflows:
| Workflow | Purpose |
|---|---|
check-links.yml | Sharded link checking using htmltest |
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 |
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 |