Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ inputs:
description: 'Max recording duration in seconds'
default: '30'
required: false
ready-timeout:
description: 'How long to wait (in seconds) for the preview URL to become reachable before failing'
default: '30'
required: false

outputs:
recording-url:
Expand Down
71 changes: 71 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Examples

## Waiting for a deploy preview (action dependencies)

A common setup is to run git-glimpse **after** another action deploys a preview
(Cloudflare Pages, Vercel, Netlify, etc.). You don't need any special
git-glimpse config for this — GitHub Actions already has the primitives.

### How it works

Use two jobs connected by `needs:`:

```yaml
jobs:
deploy:
runs-on: ubuntu-latest
outputs:
preview-url: ${{ steps.deploy.outputs.url }}
steps:
- uses: cloudflare/pages-action@v1 # or vercel, netlify, etc.
id: deploy
with: ...

glimpse:
needs: deploy # ← waits for deploy to finish
runs-on: ubuntu-latest
steps:
- uses: DeDuckProject/git-glimpse@v1
with:
preview-url: ${{ needs.deploy.outputs.preview-url }}
```

`needs: deploy` tells GitHub to run the `glimpse` job only after `deploy`
succeeds. The preview URL flows between jobs via `outputs`.

### Handling propagation delay

Even after the deploy action reports success, the preview URL may not be
immediately reachable (DNS propagation, CDN warming, etc.). git-glimpse
automatically polls the preview URL before recording, controlled by the
`ready-timeout` input (default: 30 seconds):

```yaml
- uses: DeDuckProject/git-glimpse@v1
with:
preview-url: ${{ needs.deploy.outputs.preview-url }}
ready-timeout: '60' # wait up to 60s for the URL to respond
```

### Supporting `/glimpse` comment triggers

When a user comments `/glimpse` on a PR, the deploy job typically shouldn't
re-run (the preview already exists from the original push). The example
workflow handles this with conditional logic:

- The deploy job only runs on `pull_request` events
- The glimpse job uses `always()` so it still runs when deploy is skipped
- On comment events, the preview URL falls back to a known value (e.g. a
repository variable or the URL from the original deployment)

### Full example

See [`cloudflare-deploy/workflow.yml`](cloudflare-deploy/workflow.yml) for a
complete, annotated workflow covering both PR pushes and `/glimpse` comment
triggers with Cloudflare Pages. The same pattern applies to any deploy-preview
service — just swap the deploy action and the output that carries the URL.

## Simple local app

See [`simple-app/`](simple-app/) for a minimal example that starts a local
server and records against `localhost`.
110 changes: 110 additions & 0 deletions examples/cloudflare-deploy/workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Example: git-glimpse with a Cloudflare Pages deploy preview
#
# Pattern: run the Cloudflare deploy first (job: deploy), then run
# git-glimpse against the resulting preview URL (job: glimpse).
# GitHub's `needs:` keyword handles the dependency — no extra
# git-glimpse configuration required.
#
# The same pattern works for any deploy-preview service (Vercel,
# Netlify, Railway, …). Just swap the deploy action and the output
# name that carries the preview URL.

name: Preview & Demo

on:
pull_request:
types: [opened, synchronize]
issue_comment:
types: [created]

jobs:
# ── 1. Deploy to Cloudflare Pages ─────────────────────────────────
deploy:
runs-on: ubuntu-latest
# Skip deployment for comment-triggered runs — the preview
# already exists; we only need to record a new demo.
if: github.event_name == 'pull_request'
outputs:
preview-url: ${{ steps.cf.outputs.url }}
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4

- name: Deploy to Cloudflare Pages
id: cf
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-project # replace with your project name
directory: dist # replace with your build output dir
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

# ── 2. Record a visual demo ────────────────────────────────────────
glimpse:
# Wait for the deploy job when it ran; the job is skipped on
# comment events so we use `always()` + a guard to still run.
needs: [deploy]
if: >-
always() && (
github.event_name == 'issue_comment' ||
needs.deploy.result == 'success'
) && (
github.event_name == 'pull_request' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(github.event.comment.body, '/glimpse'))
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: >-
${{
github.event_name == 'issue_comment'
&& format('refs/pull/{0}/head', github.event.issue.number)
|| ''
}}

# Lightweight check — skip expensive installs if not needed
- uses: DeDuckProject/git-glimpse/check-trigger@v1
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Install FFmpeg
if: steps.check.outputs.should-run == 'true'
run: sudo apt-get install -y ffmpeg

- name: Cache Playwright
if: steps.check.outputs.should-run == 'true'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml') }}

- name: Install Playwright Chromium
if: steps.check.outputs.should-run == 'true'
run: npx playwright install chromium --with-deps

- uses: DeDuckProject/git-glimpse@v1
if: steps.check.outputs.should-run == 'true'
with:
# On PR events the deploy job provided a fresh URL.
# On comment events the deploy job was skipped, so fall back
# to the environment variable set by Cloudflare on earlier runs,
# or supply a static staging URL if you have one.
preview-url: ${{ needs.deploy.outputs.preview-url || vars.STAGING_URL }}
# Give the preview a moment to fully propagate (default: 30s).
# Raise this if your CDN takes longer to warm up.
ready-timeout: '30'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 4 additions & 0 deletions packages/action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ inputs:
description: 'Max recording duration in seconds'
default: '30'
required: false
ready-timeout:
description: 'How long to wait (in seconds) for the preview URL to become reachable before failing'
default: '30'
required: false

outputs:
recording-url:
Expand Down
34 changes: 21 additions & 13 deletions packages/action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70361,6 +70361,20 @@ function checkApiKey(apiKey, shouldRun) {
};
}

// src/wait-for-url.ts
async function waitForUrl(url, timeout) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
}
await new Promise((r2) => setTimeout(r2, 1e3));
}
throw new Error(`App did not become ready at ${url} within ${timeout / 1e3}s`);
}

// src/index.ts
function streamCommand(cmd, args) {
return new Promise((resolve2, reject) => {
Expand Down Expand Up @@ -70396,6 +70410,8 @@ async function run() {
const previewUrlInput = core.getInput("preview-url") || void 0;
const startCommandInput = core.getInput("start-command") || void 0;
const triggerModeInput = core.getInput("trigger-mode") || void 0;
const readyTimeoutInput = core.getInput("ready-timeout");
const readyTimeoutMs = (parseInt(readyTimeoutInput, 10) || 30) * 1e3;
let config = await loadConfig(configPath);
if (previewUrlInput) {
config = { ...config, app: { ...config.app, previewUrl: previewUrlInput } };
Expand Down Expand Up @@ -70494,8 +70510,12 @@ async function run() {
(0, import_node_child_process3.execFileSync)(parts[0], parts.slice(1), { stdio: "inherit" });
}
let appProcess = null;
if (config.app.startCommand && !config.app.previewUrl) {
if (config.app.startCommand && !config.app.previewUrl && !previewUrlInput) {
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
} else if (config.app.previewUrl || previewUrlInput) {
core.info(`Waiting for preview URL to be ready: ${baseUrl}`);
await waitForUrl(baseUrl, readyTimeoutMs);
core.info("Preview URL is ready");
}
try {
core.info("Running git-glimpse pipeline...");
Expand Down Expand Up @@ -70551,18 +70571,6 @@ async function startApp(startCommand, readyUrl) {
core.info("App is ready");
return proc;
}
async function waitForUrl(url, timeout) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
}
await new Promise((r2) => setTimeout(r2, 1e3));
}
throw new Error(`App did not become ready at ${url} within ${timeout / 1e3}s`);
}
run().catch((err) => core.setFailed(err instanceof Error ? err.message : String(err)));
/*! Bundled license information:

Expand Down
8 changes: 4 additions & 4 deletions packages/action/dist/index.js.map

Large diffs are not rendered by default.

22 changes: 8 additions & 14 deletions packages/action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@git-glimpse/core';
import { resolveBaseUrl } from './resolve-base-url.js';
import { checkApiKey } from './api-key-check.js';
import { waitForUrl } from './wait-for-url.js';

function streamCommand(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -55,6 +56,8 @@ async function run(): Promise<void> {
const previewUrlInput = core.getInput('preview-url') || undefined;
const startCommandInput = core.getInput('start-command') || undefined;
const triggerModeInput = core.getInput('trigger-mode') || undefined;
const readyTimeoutInput = core.getInput('ready-timeout');
const readyTimeoutMs = (parseInt(readyTimeoutInput, 10) || 30) * 1000;

let config = await loadConfig(configPath);
if (previewUrlInput) {
Expand Down Expand Up @@ -176,8 +179,12 @@ async function run(): Promise<void> {
}

let appProcess: ReturnType<typeof spawn> | null = null;
if (config.app.startCommand && !config.app.previewUrl) {
if (config.app.startCommand && !config.app.previewUrl && !previewUrlInput) {
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
} else if (config.app.previewUrl || previewUrlInput) {
core.info(`Waiting for preview URL to be ready: ${baseUrl}`);
await waitForUrl(baseUrl, readyTimeoutMs);
core.info('Preview URL is ready');
}

try {
Expand Down Expand Up @@ -246,18 +253,5 @@ async function startApp(
return proc;
}

async function waitForUrl(url: string, timeout: number): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`App did not become ready at ${url} within ${timeout / 1000}s`);
}

run().catch((err) => core.setFailed(err instanceof Error ? err.message : String(err)));
13 changes: 13 additions & 0 deletions packages/action/src/wait-for-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export async function waitForUrl(url: string, timeout: number): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`App did not become ready at ${url} within ${timeout / 1000}s`);
}
38 changes: 38 additions & 0 deletions tests/unit/wait-for-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { waitForUrl } from '../../packages/action/src/wait-for-url.js';

afterEach(() => {
vi.restoreAllMocks();
});

describe('waitForUrl', () => {
it('resolves immediately when the URL returns ok on the first attempt', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
await expect(waitForUrl('http://example.com', 5000)).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(1);
});

it('retries until the URL becomes reachable', async () => {
let calls = 0;
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => {
calls++;
if (calls < 3) return { ok: false };
return { ok: true };
}));
// Use a generous timeout; polling delay is mocked via fake timers below
vi.useFakeTimers();
const promise = waitForUrl('http://example.com', 10000);
// Advance past two 1-second poll delays
await vi.advanceTimersByTimeAsync(2000);
vi.useRealTimers();
await promise;
expect(calls).toBe(3);
});

it('throws when the URL never becomes reachable within the timeout', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
await expect(waitForUrl('http://example.com', 500)).rejects.toThrow(
'did not become ready'
);
});
});
Loading