Skip to content

@storyshelf/notify-git

View as Markdown

@storyshelf/notify-git makes GitHub or GitLab show the current state of a build: a commit status you can require in branch protection, and a single pull-request or merge-request comment that is rewritten as the build changes. It is a state notifier family member, the sibling of @storyshelf/notify-chat and @storyshelf/notify-email, and it has no runtime dependencies: every call goes through @storyshelf/core/utils (httpJson), which adds a timeout and retries with Retry-After.

Terminal window
npm i @storyshelf/notify-git

Pass the providers to createShelfApp. There is no separate git option: git providers sit in the same notifications.notifiers list as chat and email.

import { createShelfApp } from "@storyshelf/app";
import { chatNotifiers } from "@storyshelf/notify-chat";
import { gitNotifiers } from "@storyshelf/notify-git";
const app = createShelfApp({
database,
storage,
notifications: { notifiers: [...chatNotifiers, ...gitNotifiers] },
});

gitNotifiers aggregates all three providers:

Provider kindWrites toUse for
githubGitHub commit statuses and pull-request commentsGitHub.com repositories
gitlabGitLab commit statuses and merge-request notesGitLab.com or self-managed
git-logThe server logTests and local development

The log provider's kind is git-log because notify-chat already owns log; provider kinds are unique across packages.

Providers only make a kind available. Project admins then add a git channel of that kind in Project → Settings → Notifications (the “Add a git channel” card) or through the API:

POST /api/v1/projects/{slug}/notification-channels
Content-Type: application/json
{
"provider": "github",
"config": { "owner": "acme", "repo": "widgets", "prefix": "storyshelf-web" },
"secret": "ghp_..."
}
  • config — { owner, repo, prefix?, mode?, appId?, installationId? } for GitHub (mode is statuses by default; see GitHub check runs). GitLab also takes host (default https://gitlab.com; set it to your instance URL when self-hosted). prefix names the statuses (<prefix>/testing, <prefix>/review, <prefix>/storybook) and defaults to storyshelf.
  • secret — the token. GitHub: repo:status scope. GitLab: api scope. In GitHub checks mode it is the GitHub App’s private key (PEM) instead. It is stored AES-256-GCM encrypted with the server SECRET and decrypted in memory only when a status is written. It is separate from the CLI project token: the CLI token identifies a build, this token writes to your repository.

A git channel always belongs to a project (a site-wide channel cannot be git), needs a token (or, in GitHub checks mode, the App key), and has a fixed event filter: it follows the build’s state rather than a topic list, so the event picker does not apply to it.

For every build with a commit SHA, each enabled git channel of the build’s project shows the build’s current state. One function derives it from the build, so a build that was never captured still gets its statuses, and showing the same state twice changes nothing:

StatusPosted whenMeaning
<prefix>/testingtesting is onpending while the build is captured and while review is open (its description says how many changes are left), success when every new and changed snapshot is approved, failure when changes are rejected or capture fails, cancelled when a user cancels the build (GitLab canceled, GitHub error; the description is Cancelled).
<prefix>/reviewtesting is off and the project has review onpending while the build waits for a verdict (with several required approvals the description shows the progress, Waiting for approvals: 1 of 2; default-branch builds are accepted when publishing ends and go straight to success), success once the build is approved (the project’s required approvals are present), failure when a reviewer rejects it or the build failed, cancelled when a user cancelled it. The merge gate of a hosting-only project. With review off an accepted build shows a final success (Review is off), which clears a pending status left from when review was on. With testing on this status is not posted: <prefix>/testing stays the only gate.
<prefix>/storybookevery build with a commit SHApending until publishing finishes, then success linking to the published Storybook, or failure when the build failed without a published Storybook. Informational: do not require it.

Without testing (a hosting-only instance, see Hosting only) <prefix>/review and <prefix>/storybook are posted, and <prefix>/storybook with a closing <prefix>/review success when the project has review off. Whether review is on is read from the project when the status is written, so switching it off changes the next status shown for a build. Statuses link to the build page with an absolute URL from the public origin. While none is known the statuses carry no link (target_url is omitted) and no pull-request comment is written, since its job is to link.

On top of the statuses, one comment on the pull request (GitHub) or merge request (GitLab) of the commit links the build page and the published Storybook and summarizes the visual tests and snapshot counts. It is keyed by a hidden marker built from the build URL, so it is updated in place, never duplicated. A commit with no open pull request gets no comment.

Builds with a synthetic local identity (uploads without git, see Without git) post nothing. Branch protection setup is in Merge gate setup.

A GitHub channel shows a build in one of two ways, set by mode:

  • statuses (default): commit statuses, written with a personal or fine-grained token. This is what the table above describes.
  • checks: check runs, written by a GitHub App installed on the repository, with no personal access token. config takes appId and installationId (numbers from the App’s settings and the installation URL) and the channel’s secret is the App’s private key. The key is stored encrypted like any channel secret, is never returned by the API or shown again in the UI (the channel list says only that a secret is set), and is scrubbed from logs and error text. Merge gate setup walks through creating the App.

Check runs sit beside statuses, not on top of them: a channel uses one mode. They are driven by the same function as the statuses, so each status above becomes a run with the same name (<prefix>/testing, <prefix>/review, <prefix>/storybook), which is also the name you require in branch protection. GitLab has no check runs; a GitLab channel always posts commit statuses.

Build stateCheck run
Not captured yetqueued
Capturing, comparing, review open, waiting for a verdictin_progress
Approvedcompleted, success
Rejected, or capture failedcompleted, failure
Cancelled by a usercompleted, cancelled
Storybook publish failed (<prefix>/storybook, informational)completed, neutral

A run is found by the commit SHA and a stable external id (storyshelf:<name>) and updated as the build moves, so a commit shows one run per name however many times the state is written. Its details link is the absolute build URL (nothing is posted while no public origin is confirmed).

Annotations. <prefix>/testing lists every new or changed snapshot as an annotation titled with the story, viewport and browser, anchored at the story file, whose details carry the review URL of that snapshot (…/builds/<id>/diff?snapshot=<id>). GitHub accepts 50 annotations per request, so they are posted in batches of 50 through check-run updates; at most 200 are posted, and the summary then says and N more changed snapshots with a link to the build. A repeated delivery skips the annotations the run already shows. GitHub never removes annotations: after you approve a change its annotation stays on the run, while the run itself completes as success.

The App needs these repository permissions: Checks: read and write (the check runs), Pull requests: read and write (the pull-request comment) and Contents: read (to resolve the commit). Commit-status write access is optional and not used in checks mode. The Administration permission is only for the storyshelf protect helper, which runs with your own admin token, not the App.

Authentication uses no library: the provider signs an RS256 JWT with the key, exchanges it for an installation token through the shared httpJson helper, and caches that token per channel until about five minutes before it expires.

The status name is <prefix>/<status>; the channel’s prefix defaults to storyshelf and carries no project slug. A repository that holds several projects gives each project’s channel its own prefix (storyshelf-web, storyshelf-docs), so their statuses do not collide and each can be required on its own. The prefix may contain letters, digits, dots, dashes and underscores.

State delivery needs a confirmed public origin (PUBLIC_BASE_URL, the platform’s address or one an admin confirmed; the address of a browser counts only once confirmed): every status and the comment link to the build, so the package works with no other base URL. A hosting-only instance needs it too, and then still posts <prefix>/storybook with the published Storybook’s URL. While none resolves nothing is queued or posted, and no channel shows an error. When an admin confirms an origin on System, the builds still pending or in review are shown again; with PUBLIC_BASE_URL set at start-up, delivery resumes with the next state change.

State delivery is durable and latest-wins. Every build transition and the end of publishing queue one notify job per git channel on the database queue, next to capture and publish jobs, with a consumer of its own so a status never waits behind a running capture.

  • A job names a channel and a build, not a status. When it runs it reads the build as it is then and writes that, so a retried, delayed or reordered job can never post a stale status. A request for a channel and build that already has a job waiting is folded into it.
  • A refused write (a revoked token, a missing scope, an unreachable host) is retried up to five attempts in total, waiting 10 s, 20 s, 40 s and 80 s between them. Outbound calls also have the shared HTTP helper’s timeout and Retry-After handling.
  • After the last attempt the failure is logged, the channel row keeps the last error and its time, and a sys:notify-failed event goes to the site-wide channels. The Notifications settings page shows the channel as failing with that error until the next successful delivery clears it; the notification-channel API returns the same fields as lastError and lastErrorAt.
  • Writes are idempotent and nothing is read from the git host during capture; a build of a commit that is already merged is captured like any other.

A status can lag the build change by a queue polling interval. On a host with no queue consumer (a hosting-only instance on a serverless platform) there is nothing to retry from, so the state is delivered inline in the request or publish path, without durability: a failure is recorded on the channel and the next transition writes the current state again. Chat and email channels are not part of this: messages are sent inline and best-effort, and a failure never fails the triggering action.

Each provider is a StateNotifierProvider from @storyshelf/core/adapter/notifier. Bind one to a channel and call setState:

import { gitHubNotifier } from "@storyshelf/notify-git";
const adapter = gitHubNotifier.create({
config: { owner: "acme", repo: "widgets" },
secret: process.env.GITHUB_TOKEN,
});
await adapter.setState({
channel,
target: { kind: "status", sha, context: "storyshelf/testing", description: "Review closed" },
state: "success",
url: "https://shelf.example.com/projects/web/builds/01J...",
});

The status context is used verbatim: the caller owns the prefix, the provider adds nothing. StoryShelf derives the contexts, descriptions and links itself; direct use is for custom hosts. A { kind: "comment", sha, markdown } target upserts the review comment instead.

See Notifications for channels, subscriptions and events, and ADR 0014 for the decision record.