@tempo-ai/visual-review 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ export { CaptureSession, looksBlank, sha256Hex, startCaptureSession, storyboardRenderId } from './src/capture';
2
+ export { CHROMIUM_LAUNCH_ARGS, FREEZE_MOTION_CSS, FROZEN_EPOCH_MS, buildFreezeInitScript, settlePage } from './src/determinism';
3
+ export { DevserverSession, startDevserverSession } from './src/devserver';
4
+ export { DEVICE_SCALE_FACTOR, FONTS_BUNDLE_VERSION, KIT_VERSION, buildEnvFingerprint } from './src/fingerprint';
5
+ export { TimingReport, percentile, summarize } from './src/timing';
6
+ import { CaptureStatus } from './src/types';
7
+ export { CaptureStatus, EnvFingerprint } from './src/types';
8
+
9
+ declare function main$3(argv?: string[]): Promise<number>;
10
+
11
+ type VrtIgnoreRegion = {
12
+ x: number;
13
+ y: number;
14
+ w: number;
15
+ h: number;
16
+ };
17
+ /** One tracked storyboard, as enumerated from the canvas AST. */
18
+ type TrackedStoryboard = {
19
+ /** Canvas page folder path relative to the tempo dir — the store's
20
+ * `canvasPath` and the Design tab's comment `contentPath`, e.g.
21
+ * "designs/canvases/buttons". */
22
+ canvasPath: string;
23
+ /** The canvas file path relative to the PROJECT root (the canvas-host
24
+ * module key), e.g. "tempo/designs/canvases/buttons/index.canvas.tsx" —
25
+ * what the direct-render URL's `canvasPath` param takes. */
26
+ hostCanvasPath: string;
27
+ storyboardId: string;
28
+ name?: string;
29
+ layout: {
30
+ x: number;
31
+ y: number;
32
+ width: number;
33
+ height: number;
34
+ };
35
+ threshold?: number;
36
+ ignore?: VrtIgnoreRegion[];
37
+ };
38
+ type EnumerationResult = {
39
+ tracked: TrackedStoryboard[];
40
+ /** Tracked but `vrt={{ skip: true }}` — paused: rides the manifest with
41
+ * `skip: true` (so pausing is not misread as deletion) but is neither
42
+ * captured nor gated. */
43
+ paused: TrackedStoryboard[];
44
+ /** Total storyboards seen (tracked or not) — for the log line. */
45
+ totalStoryboards: number;
46
+ totalCanvases: number;
47
+ /** Structural parse errors — enumeration is AST truth, so these fail the
48
+ * run loudly instead of silently shrinking the manifest. */
49
+ errors: Array<{
50
+ filePath: string;
51
+ message: string;
52
+ }>;
53
+ };
54
+ type RunManifest = {
55
+ repoKey: string;
56
+ githubRepo?: string;
57
+ headSha: string;
58
+ branch: string;
59
+ trigger: "pr" | "main" | "manual";
60
+ prNumber?: number;
61
+ baseCandidates: string[];
62
+ /** Opaque lease ownership proof when this run is publishing a baseline
63
+ * claimed before rendering began. */
64
+ baselineClaimToken?: string;
65
+ envFingerprint: string;
66
+ expected: Array<TrackedStoryboard["layout"] & {
67
+ canvasPath: string;
68
+ storyboardId: string;
69
+ name?: string;
70
+ threshold?: number;
71
+ ignore?: VrtIgnoreRegion[];
72
+ /** Paused (`vrt={{ skip: true }}`) — in the manifest, not gated. */
73
+ skip?: boolean;
74
+ }>;
75
+ snapshots: Array<{
76
+ canvasPath: string;
77
+ storyboardId: string;
78
+ pngHash?: string;
79
+ htmlHash?: string;
80
+ status: CaptureStatus;
81
+ error?: string;
82
+ }>;
83
+ };
84
+
85
+ /**
86
+ * The producer credential. In production (and any customer repo) this is a
87
+ * GitHub Actions OIDC token minted with `audience: tempo-visual-review`; the
88
+ * global shared secret is accepted only by non-prod deployments (previews,
89
+ * e2e, local capture). See the Convex `visualReviewAuthenticate` gate.
90
+ */
91
+ type IngestCredential = {
92
+ kind: "oidc";
93
+ value: string;
94
+ } | {
95
+ kind: "secret";
96
+ value: string;
97
+ };
98
+ /**
99
+ * A credential source. GitHub OIDC id-tokens expire 5 minutes after minting,
100
+ * and a capture run spans many minutes between its first authenticated call
101
+ * (the mid-capture baseline fetch) and its last (finalize) — so the client
102
+ * resolves the credential PER REQUEST from a provider that can re-mint,
103
+ * instead of pinning one token at construction.
104
+ */
105
+ type IngestCredentialProvider = () => Promise<IngestCredential>;
106
+ type BaselineClaimResult = {
107
+ status: "unsupported";
108
+ reason?: string;
109
+ } | {
110
+ status: "ready";
111
+ commitSha: string;
112
+ } | {
113
+ status: "capturing";
114
+ commitSha: string;
115
+ leaseExpiresAt: number;
116
+ } | {
117
+ status: "claimed";
118
+ commitSha: string;
119
+ leaseExpiresAt: number;
120
+ };
121
+ type BaselineClaimIdentity = {
122
+ repoKey: string;
123
+ commitSha: string;
124
+ envFingerprint: string;
125
+ ownerToken: string;
126
+ };
127
+ declare class VisualReviewHttpError extends Error {
128
+ readonly status: number;
129
+ constructor(message: string, status: number);
130
+ }
131
+ /**
132
+ * Client for the Convex `/visual-review/*` ingest routes (TEM-1775 §4.3):
133
+ * hash-negotiated, idempotent, resumable. A retried CI job re-uploads
134
+ * nothing (negotiate reports every hash present) and re-finalizes safely
135
+ * (the mutation dedups on repo+sha+env+trigger+branch).
136
+ */
137
+ declare class VisualReviewIngestClient {
138
+ /** Convex site origin, e.g. https://dapper-heron-945.convex.site */
139
+ private readonly baseUrl;
140
+ private readonly credential;
141
+ private lastCredentialKind;
142
+ constructor(baseUrl: string, credential: IngestCredential | IngestCredentialProvider);
143
+ private authHeader;
144
+ private request;
145
+ /** Best-effort read of the typed `reason`/`error` from an error body. */
146
+ private errorReason;
147
+ /** → the subset of `hashes` the store lacks. */
148
+ negotiate(hashes: string[]): Promise<string[]>;
149
+ uploadBlob(hash: string, bytes: Uint8Array, size: {
150
+ width: number;
151
+ height: number;
152
+ }): Promise<void>;
153
+ claimBaseline(input: {
154
+ repoKey: string;
155
+ commitSha: string;
156
+ envFingerprint: string;
157
+ ownerToken: string;
158
+ }): Promise<BaselineClaimResult>;
159
+ renewBaselineClaim(input: BaselineClaimIdentity): Promise<{
160
+ renewed: true;
161
+ leaseExpiresAt: number;
162
+ }>;
163
+ failBaselineClaim(input: Omit<BaselineClaimIdentity, "envFingerprint"> & {
164
+ envFingerprint?: string;
165
+ error: string;
166
+ }): Promise<{
167
+ released: boolean;
168
+ }>;
169
+ baselineManifest(repoKey: string, commitSha: string, envFingerprint: string): Promise<Pick<RunManifest, "expected" | "snapshots">>;
170
+ finalize(manifest: RunManifest): Promise<{
171
+ runId: string;
172
+ reportKey: string;
173
+ state: string;
174
+ deduped: boolean;
175
+ reportUrl: string;
176
+ statusUrl: string;
177
+ }>;
178
+ }
179
+
180
+ declare function resolveOrClaimBaseline(options: {
181
+ ingest: VisualReviewIngestClient;
182
+ repoKey: string;
183
+ commitSha: string;
184
+ envFingerprint: string;
185
+ ownerToken: string;
186
+ log: (line: string) => void;
187
+ waitTimeoutMs?: number;
188
+ pollIntervalMs?: number;
189
+ now?: () => number;
190
+ sleep?: (ms: number) => Promise<void>;
191
+ }): Promise<BaselineClaimResult>;
192
+ declare function main$2(argv?: string[]): Promise<number>;
193
+
194
+ /** The production Convex HTTP origin customers ingest visual-review runs to. */
195
+ declare const DEFAULT_VISUAL_REVIEW_INGEST_URL = "https://greedy-jackal-526.convex.site";
196
+
197
+ /**
198
+ * Enumerates the tracked set (TEM-1775 §3.2): every `<Storyboard>` under the
199
+ * canvases root carrying a literal `vrt` prop, minus `skip: true`. Route and
200
+ * html storyboards are out of scope in v1 — the parser only attaches `vrt`
201
+ * to component storyboards, so they can never enter the manifest.
202
+ *
203
+ * The generic AST walk lives in `@tempo-modules/canvas-capture-headless`;
204
+ * this layers the vrt POLICY on top:
205
+ * - Structural parse errors FAIL enumeration for that file (its storyboards
206
+ * can't be trusted); they are returned for the caller to report loudly
207
+ * rather than silently shrinking the tracked set.
208
+ * - A malformed `vrt` declaration (dynamic expression, bad threshold,
209
+ * broken ignore region) is DEGRADED to the editor but FATAL here: the
210
+ * author expressed tracking intent, and silently proceeding would either
211
+ * drop the board from the gate or gate it with different settings than
212
+ * written.
213
+ * - An ignore configuration masking ≥90% of the board is fatal — "tracked"
214
+ * must not be a lie.
215
+ */
216
+ declare function enumerateTrackedStoryboards(options: {
217
+ /** The tempo dir (holding tempo.config.json), e.g. `<repo>/tempo`. */
218
+ tempoDir: string;
219
+ /** The canvases root, e.g. `<repo>/tempo/designs`. */
220
+ pagesDir: string;
221
+ }): Promise<EnumerationResult>;
222
+
223
+ /**
224
+ * Authors declare `vrt.ignore` regions in storyboard-local CSS px; the
225
+ * compare engine masks the DEVICE-pixel snapshot. The run manifest carries
226
+ * device px — this is the single conversion point.
227
+ */
228
+ declare function scaleIgnoreRegionsToDevicePx(regions: VrtIgnoreRegion[] | undefined): VrtIgnoreRegion[] | undefined;
229
+
230
+ /**
231
+ * `tempo-visual-review install-browsers` — install the Chromium build that
232
+ * matches the playwright THIS package depends on.
233
+ *
234
+ * A bare `npx playwright install` resolves registry-latest playwright and can
235
+ * fetch a browser revision the pinned driver cannot launch; resolving the
236
+ * installer from our own dependency tree removes that drift for every
237
+ * consumer of the generated workflow. Linux adds `--with-deps` (Chromium's
238
+ * shared libraries via apt) because CI VMs are ephemeral; other platforms
239
+ * already carry those libraries.
240
+ */
241
+ declare function main$1(argv?: string[]): Promise<number>;
242
+
243
+ declare function main(argv?: string[]): Promise<number>;
244
+
245
+ export { type BaselineClaimIdentity, type BaselineClaimResult, DEFAULT_VISUAL_REVIEW_INGEST_URL, type EnumerationResult, type RunManifest, type TrackedStoryboard, VisualReviewHttpError, VisualReviewIngestClient, type VrtIgnoreRegion, enumerateTrackedStoryboards, resolveOrClaimBaseline, main$2 as runCaptureCli, main$3 as runClaimCli, main$1 as runInstallBrowsersCli, main as runReportCli, scaleIgnoreRegionsToDevicePx };
package/dist/index.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createRequire as __tempoCreateRequire } from 'node:module'; const require = __tempoCreateRequire(import.meta.url);
2
+ import {
3
+ main
4
+ } from "./chunk-DJGBIRN5.js";
5
+ import {
6
+ CHROMIUM_LAUNCH_ARGS,
7
+ DEVICE_SCALE_FACTOR,
8
+ FONTS_BUNDLE_VERSION,
9
+ FREEZE_MOTION_CSS,
10
+ FROZEN_EPOCH_MS,
11
+ KIT_VERSION,
12
+ TimingReport,
13
+ VisualReviewHttpError,
14
+ VisualReviewIngestClient,
15
+ buildEnvFingerprint,
16
+ buildFreezeInitScript,
17
+ enumerateTrackedStoryboards,
18
+ looksBlank,
19
+ main as main2,
20
+ percentile,
21
+ resolveOrClaimBaseline,
22
+ scaleIgnoreRegionsToDevicePx,
23
+ settlePage,
24
+ sha256Hex,
25
+ startCaptureSession,
26
+ startDevserverSession,
27
+ storyboardRenderId,
28
+ summarize
29
+ } from "./chunk-4QAZFBC3.js";
30
+ import {
31
+ DEFAULT_VISUAL_REVIEW_INGEST_URL
32
+ } from "./chunk-QLAJNWA2.js";
33
+ import {
34
+ main as main3
35
+ } from "./chunk-EYRAT56C.js";
36
+ import {
37
+ main as main4
38
+ } from "./chunk-HMPQK5ZG.js";
39
+ import "./chunk-W7PBID4V.js";
40
+ export {
41
+ CHROMIUM_LAUNCH_ARGS,
42
+ DEFAULT_VISUAL_REVIEW_INGEST_URL,
43
+ DEVICE_SCALE_FACTOR,
44
+ FONTS_BUNDLE_VERSION,
45
+ FREEZE_MOTION_CSS,
46
+ FROZEN_EPOCH_MS,
47
+ KIT_VERSION,
48
+ TimingReport,
49
+ VisualReviewHttpError,
50
+ VisualReviewIngestClient,
51
+ buildEnvFingerprint,
52
+ buildFreezeInitScript,
53
+ enumerateTrackedStoryboards,
54
+ looksBlank,
55
+ percentile,
56
+ resolveOrClaimBaseline,
57
+ main2 as runCaptureCli,
58
+ main as runClaimCli,
59
+ main3 as runInstallBrowsersCli,
60
+ main4 as runReportCli,
61
+ scaleIgnoreRegionsToDevicePx,
62
+ settlePage,
63
+ sha256Hex,
64
+ startCaptureSession,
65
+ startDevserverSession,
66
+ storyboardRenderId,
67
+ summarize
68
+ };
69
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,8 @@
1
+ import { createRequire as __tempoCreateRequire } from 'node:module'; const require = __tempoCreateRequire(import.meta.url);
2
+ import {
3
+ main
4
+ } from "./chunk-EYRAT56C.js";
5
+ export {
6
+ main
7
+ };
8
+ //# sourceMappingURL=install-browsers-5RCJN3AK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,9 @@
1
+ import { createRequire as __tempoCreateRequire } from 'node:module'; const require = __tempoCreateRequire(import.meta.url);
2
+ import {
3
+ main
4
+ } from "./chunk-HMPQK5ZG.js";
5
+ import "./chunk-W7PBID4V.js";
6
+ export {
7
+ main
8
+ };
9
+ //# sourceMappingURL=report-RCKB2CVS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tempo-ai/visual-review",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "tempo-visual-review": "./dist/bin.js"
7
+ },
8
+ "files": [
9
+ "dist/",
10
+ "templates/",
11
+ "README.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ }
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/TempoLabsAI/tempo-monorepo.git",
25
+ "directory": "tempo-modules/visual-review"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "dependencies": {
31
+ "@parcel/watcher": "^2.5.1",
32
+ "oxc-parser": "^0.110.0",
33
+ "picomatch": "^4.0.5",
34
+ "playwright": "1.58.2",
35
+ "pngjs": "^7.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@tempo-modules/canvas-capture-headless": "workspace:*",
39
+ "@types/node": "^25.0.8",
40
+ "@types/pngjs": "^6.0.5",
41
+ "tsup": "^8.5.1",
42
+ "vitest": "^3.1.1"
43
+ },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "capture": "bun src/cli.ts",
47
+ "prepare": "tsup",
48
+ "prepublishOnly": "pnpm build && pnpm test",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest"
51
+ }
52
+ }
@@ -0,0 +1,169 @@
1
+ # Written by Tempo visual-review onboarding (visual_review_init). The pnpm
2
+ # setup and the two install lines are the package-manager-specific part;
3
+ # init rewrites them for npm / yarn / bun. Keep every
4
+ # `npx --yes @tempo-ai/visual-review@latest …` line as it is: Tempo's own
5
+ # repository runs these exact commands, so each release is exercised there
6
+ # before it reaches you. Nothing from your checkout executes — the checkout
7
+ # is the subject of the capture.
8
+ name: Visual Review
9
+
10
+ on:
11
+ pull_request:
12
+ # labeled/unlabeled: the bypass label takes effect (and is revocable)
13
+ # without pushing a new commit.
14
+ types: [opened, synchronize, reopened, edited, labeled, unlabeled]
15
+ push:
16
+ # Your default branch: pushes here publish the baseline PRs compare against.
17
+ branches: [main]
18
+
19
+ permissions:
20
+ contents: read
21
+ statuses: write
22
+ pull-requests: write
23
+ # GitHub OIDC is the only credential Tempo accepts. No secret is stored.
24
+ id-token: write
25
+
26
+ concurrency:
27
+ # A new push never queues behind a stale run. Label events get their own
28
+ # group so an unrelated label added mid-capture cannot cancel it.
29
+ group: visual-review-${{ github.event.pull_request.number || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && 'label' || 'code' }}
30
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
31
+
32
+ jobs:
33
+ # `visual-review:bypass` label — the incident escape hatch. Posts success
34
+ # and skips capture entirely.
35
+ bypass:
36
+ if: >-
37
+ github.event_name == 'pull_request'
38
+ && contains(github.event.pull_request.labels.*.name, 'visual-review:bypass')
39
+ runs-on: ubuntu-latest
40
+ timeout-minutes: 5
41
+ steps:
42
+ - run: npx --yes @tempo-ai/visual-review@latest report --bypass --sha ${{ github.event.pull_request.head.sha }}
43
+ env:
44
+ GH_TOKEN: ${{ github.token }}
45
+
46
+ # `<!-- tempo-no-visual-change -->` in the PR body — a 100%-certainty claim
47
+ # that the PR changes no UI and no tested storyboard. Posts success and
48
+ # skips capture.
49
+ no-visual-change:
50
+ if: >-
51
+ github.event_name == 'pull_request'
52
+ && !contains(github.event.pull_request.labels.*.name, 'visual-review:bypass')
53
+ && contains(github.event.pull_request.body, '<!-- tempo-no-visual-change -->')
54
+ runs-on: ubuntu-latest
55
+ timeout-minutes: 5
56
+ steps:
57
+ - run: npx --yes @tempo-ai/visual-review@latest report --no-visual-change --pr ${{ github.event.pull_request.number }} --sha ${{ github.event.pull_request.head.sha }}
58
+ env:
59
+ GH_TOKEN: ${{ github.token }}
60
+
61
+ capture:
62
+ # Pushes to the default branch, real code events without either skip
63
+ # declaration, and bypass-label removal (to restore the real gate).
64
+ if: >-
65
+ !cancelled()
66
+ && (
67
+ github.event_name == 'push'
68
+ || (
69
+ contains(fromJSON('["opened","synchronize","reopened","edited"]'), github.event.action)
70
+ && !contains(github.event.pull_request.labels.*.name, 'visual-review:bypass')
71
+ && !contains(github.event.pull_request.body, '<!-- tempo-no-visual-change -->')
72
+ )
73
+ || (
74
+ github.event.action == 'unlabeled'
75
+ && github.event.label.name == 'visual-review:bypass'
76
+ && !contains(github.event.pull_request.body, '<!-- tempo-no-visual-change -->')
77
+ )
78
+ )
79
+ runs-on: ubuntu-latest
80
+ # A PR may first wait for another run's baseline and then render both the
81
+ # merge-base and its own head.
82
+ timeout-minutes: 90
83
+ outputs:
84
+ status_url: ${{ steps.capture.outputs.status_url }}
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ with:
88
+ # Full history: the merge-base ancestor chain is the baseline key.
89
+ fetch-depth: 0
90
+ # Capture the PR head, not GitHub's synthetic merge commit.
91
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
92
+
93
+ # Announces intent before dependency/browser setup so stacked PRs wait
94
+ # on the baseline lease instead of duplicating a capture. Purely an
95
+ # optimization, so a registry hiccup must not fail the run.
96
+ - run: npx --yes @tempo-ai/visual-review@latest claim || echo "::warning::capture claim announcement unavailable; capture claims after setup"
97
+
98
+ - if: github.event_name == 'pull_request'
99
+ run: |
100
+ gh api "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
101
+ -f state=pending -f context=visual-review \
102
+ -f "description=capturing storyboards…"
103
+ env:
104
+ GH_TOKEN: ${{ github.token }}
105
+
106
+ - uses: pnpm/action-setup@v4
107
+ - uses: actions/setup-node@v4
108
+ with:
109
+ node-version: 20
110
+ cache: pnpm
111
+ - run: pnpm install --frozen-lockfile
112
+ # The canvas host under `tempo/` is its own package. Capture never
113
+ # mutates this checkout (TEMPO_DEVSERVER_PROTECTED_ROOTS below), so the
114
+ # host's dependencies are installed here, explicitly.
115
+ - run: cd tempo && pnpm install --no-frozen-lockfile
116
+
117
+ # Chromium matching the CLI's own pinned playwright, plus its shared
118
+ # libraries on Linux.
119
+ - run: npx --yes @tempo-ai/visual-review@latest install-browsers
120
+
121
+ - uses: actions/cache@v4
122
+ with:
123
+ path: tempo/node_modules/.vite
124
+ key: visual-review-vite-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
125
+
126
+ # `capture` infers PR-vs-main, head SHA, branch, merge-base and
127
+ # repository from GitHub's event payload and the standard GITHUB_*
128
+ # environment, and authenticates with short-lived OIDC tokens it mints
129
+ # itself.
130
+ - id: capture
131
+ run: npx --yes @tempo-ai/visual-review@latest capture --parallel 2
132
+ env:
133
+ # Forbid the capture's canvas-host supervisor from running package
134
+ # manager installs or updates inside this checkout; those would
135
+ # churn the Vite cache mid-run and rewrite lockfiles.
136
+ TEMPO_DEVSERVER_PROTECTED_ROOTS: ${{ github.workspace }}
137
+
138
+ # A finished capture has already consumed its claim; this only releases
139
+ # a lease left behind by a setup failure or cancellation.
140
+ - if: always()
141
+ run: npx --yes @tempo-ai/visual-review@latest claim --release || echo "::warning::capture claim release unavailable; the lease expires on its own"
142
+
143
+ - if: failure() && github.event_name == 'pull_request'
144
+ run: |
145
+ gh api "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
146
+ -f state=failure -f context=visual-review \
147
+ -f "description=capture failed — see workflow logs"
148
+ env:
149
+ GH_TOKEN: ${{ github.token }}
150
+
151
+ # Polls the run until Tempo has compared it, then posts the verdict and the
152
+ # sticky PR comment. Approvals in the Tempo report flip the check afterwards
153
+ # without a CI re-run.
154
+ report:
155
+ needs: capture
156
+ if: github.event_name == 'pull_request'
157
+ runs-on: ubuntu-latest
158
+ timeout-minutes: 20
159
+ steps:
160
+ - run: npx --yes @tempo-ai/visual-review@latest report --status-url "${{ needs.capture.outputs.status_url }}" --pr ${{ github.event.pull_request.number }} --sha ${{ github.event.pull_request.head.sha }}
161
+ env:
162
+ GH_TOKEN: ${{ github.token }}
163
+
164
+ # A crashed report must not strand the check at pending. Only stomps
165
+ # pending/absent — a verdict Tempo already delivered stays.
166
+ - if: failure()
167
+ run: npx --yes @tempo-ai/visual-review@latest report --fallback-failure --sha ${{ github.event.pull_request.head.sha }}
168
+ env:
169
+ GH_TOKEN: ${{ github.token }}