Skip to content

Commit 9737234

Browse files
Merge pull request #56 from DeDuckProject/claude/github-action-dependencies-Gl7bx
Add preview URL readiness check and Cloudflare deploy example
2 parents e0d24fe + cff6a42 commit 9737234

File tree

9 files changed

+273
-31
lines changed

9 files changed

+273
-31
lines changed

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ inputs:
2727
description: 'Max recording duration in seconds'
2828
default: '30'
2929
required: false
30+
ready-timeout:
31+
description: 'How long to wait (in seconds) for the preview URL to become reachable before failing'
32+
default: '30'
33+
required: false
3034

3135
outputs:
3236
recording-url:

examples/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Examples
2+
3+
## Waiting for a deploy preview (action dependencies)
4+
5+
A common setup is to run git-glimpse **after** another action deploys a preview
6+
(Cloudflare Pages, Vercel, Netlify, etc.). You don't need any special
7+
git-glimpse config for this — GitHub Actions already has the primitives.
8+
9+
### How it works
10+
11+
Use two jobs connected by `needs:`:
12+
13+
```yaml
14+
jobs:
15+
deploy:
16+
runs-on: ubuntu-latest
17+
outputs:
18+
preview-url: ${{ steps.deploy.outputs.url }}
19+
steps:
20+
- uses: cloudflare/pages-action@v1 # or vercel, netlify, etc.
21+
id: deploy
22+
with: ...
23+
24+
glimpse:
25+
needs: deploy # ← waits for deploy to finish
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: DeDuckProject/git-glimpse@v1
29+
with:
30+
preview-url: ${{ needs.deploy.outputs.preview-url }}
31+
```
32+
33+
`needs: deploy` tells GitHub to run the `glimpse` job only after `deploy`
34+
succeeds. The preview URL flows between jobs via `outputs`.
35+
36+
### Handling propagation delay
37+
38+
Even after the deploy action reports success, the preview URL may not be
39+
immediately reachable (DNS propagation, CDN warming, etc.). git-glimpse
40+
automatically polls the preview URL before recording, controlled by the
41+
`ready-timeout` input (default: 30 seconds):
42+
43+
```yaml
44+
- uses: DeDuckProject/git-glimpse@v1
45+
with:
46+
preview-url: ${{ needs.deploy.outputs.preview-url }}
47+
ready-timeout: '60' # wait up to 60s for the URL to respond
48+
```
49+
50+
### Supporting `/glimpse` comment triggers
51+
52+
When a user comments `/glimpse` on a PR, the deploy job typically shouldn't
53+
re-run (the preview already exists from the original push). The example
54+
workflow handles this with conditional logic:
55+
56+
- The deploy job only runs on `pull_request` events
57+
- The glimpse job uses `always()` so it still runs when deploy is skipped
58+
- On comment events, the preview URL falls back to a known value (e.g. a
59+
repository variable or the URL from the original deployment)
60+
61+
### Full example
62+
63+
See [`cloudflare-deploy/workflow.yml`](cloudflare-deploy/workflow.yml) for a
64+
complete, annotated workflow covering both PR pushes and `/glimpse` comment
65+
triggers with Cloudflare Pages. The same pattern applies to any deploy-preview
66+
service — just swap the deploy action and the output that carries the URL.
67+
68+
## Simple local app
69+
70+
See [`simple-app/`](simple-app/) for a minimal example that starts a local
71+
server and records against `localhost`.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Example: git-glimpse with a Cloudflare Pages deploy preview
2+
#
3+
# Pattern: run the Cloudflare deploy first (job: deploy), then run
4+
# git-glimpse against the resulting preview URL (job: glimpse).
5+
# GitHub's `needs:` keyword handles the dependency — no extra
6+
# git-glimpse configuration required.
7+
#
8+
# The same pattern works for any deploy-preview service (Vercel,
9+
# Netlify, Railway, …). Just swap the deploy action and the output
10+
# name that carries the preview URL.
11+
12+
name: Preview & Demo
13+
14+
on:
15+
pull_request:
16+
types: [opened, synchronize]
17+
issue_comment:
18+
types: [created]
19+
20+
jobs:
21+
# ── 1. Deploy to Cloudflare Pages ─────────────────────────────────
22+
deploy:
23+
runs-on: ubuntu-latest
24+
# Skip deployment for comment-triggered runs — the preview
25+
# already exists; we only need to record a new demo.
26+
if: github.event_name == 'pull_request'
27+
outputs:
28+
preview-url: ${{ steps.cf.outputs.url }}
29+
permissions:
30+
contents: read
31+
pull-requests: write
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Deploy to Cloudflare Pages
36+
id: cf
37+
uses: cloudflare/pages-action@v1
38+
with:
39+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
40+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
41+
projectName: my-project # replace with your project name
42+
directory: dist # replace with your build output dir
43+
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
44+
45+
# ── 2. Record a visual demo ────────────────────────────────────────
46+
glimpse:
47+
# Wait for the deploy job when it ran; the job is skipped on
48+
# comment events so we use `always()` + a guard to still run.
49+
needs: [deploy]
50+
if: >-
51+
always() && (
52+
github.event_name == 'issue_comment' ||
53+
needs.deploy.result == 'success'
54+
) && (
55+
github.event_name == 'pull_request' ||
56+
(github.event_name == 'issue_comment' &&
57+
github.event.issue.pull_request != null &&
58+
contains(github.event.comment.body, '/glimpse'))
59+
)
60+
runs-on: ubuntu-latest
61+
permissions:
62+
contents: write
63+
pull-requests: write
64+
issues: write
65+
steps:
66+
- uses: actions/checkout@v4
67+
with:
68+
fetch-depth: 0
69+
ref: >-
70+
${{
71+
github.event_name == 'issue_comment'
72+
&& format('refs/pull/{0}/head', github.event.issue.number)
73+
|| ''
74+
}}
75+
76+
# Lightweight check — skip expensive installs if not needed
77+
- uses: DeDuckProject/git-glimpse/check-trigger@v1
78+
id: check
79+
env:
80+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81+
82+
- name: Install FFmpeg
83+
if: steps.check.outputs.should-run == 'true'
84+
run: sudo apt-get install -y ffmpeg
85+
86+
- name: Cache Playwright
87+
if: steps.check.outputs.should-run == 'true'
88+
uses: actions/cache@v4
89+
with:
90+
path: ~/.cache/ms-playwright
91+
key: playwright-chromium-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml') }}
92+
93+
- name: Install Playwright Chromium
94+
if: steps.check.outputs.should-run == 'true'
95+
run: npx playwright install chromium --with-deps
96+
97+
- uses: DeDuckProject/git-glimpse@v1
98+
if: steps.check.outputs.should-run == 'true'
99+
with:
100+
# On PR events the deploy job provided a fresh URL.
101+
# On comment events the deploy job was skipped, so fall back
102+
# to the environment variable set by Cloudflare on earlier runs,
103+
# or supply a static staging URL if you have one.
104+
preview-url: ${{ needs.deploy.outputs.preview-url || vars.STAGING_URL }}
105+
# Give the preview a moment to fully propagate (default: 30s).
106+
# Raise this if your CDN takes longer to warm up.
107+
ready-timeout: '30'
108+
env:
109+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
110+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

packages/action/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ inputs:
2727
description: 'Max recording duration in seconds'
2828
default: '30'
2929
required: false
30+
ready-timeout:
31+
description: 'How long to wait (in seconds) for the preview URL to become reachable before failing'
32+
default: '30'
33+
required: false
3034

3135
outputs:
3236
recording-url:

packages/action/dist/index.js

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70361,6 +70361,20 @@ function checkApiKey(apiKey, shouldRun) {
7036170361
};
7036270362
}
7036370363

70364+
// src/wait-for-url.ts
70365+
async function waitForUrl(url, timeout) {
70366+
const deadline = Date.now() + timeout;
70367+
while (Date.now() < deadline) {
70368+
try {
70369+
const res = await fetch(url);
70370+
if (res.ok) return;
70371+
} catch {
70372+
}
70373+
await new Promise((r2) => setTimeout(r2, 1e3));
70374+
}
70375+
throw new Error(`App did not become ready at ${url} within ${timeout / 1e3}s`);
70376+
}
70377+
7036470378
// src/index.ts
7036570379
function streamCommand(cmd, args) {
7036670380
return new Promise((resolve2, reject) => {
@@ -70396,6 +70410,8 @@ async function run() {
7039670410
const previewUrlInput = core.getInput("preview-url") || void 0;
7039770411
const startCommandInput = core.getInput("start-command") || void 0;
7039870412
const triggerModeInput = core.getInput("trigger-mode") || void 0;
70413+
const readyTimeoutInput = core.getInput("ready-timeout");
70414+
const readyTimeoutMs = (parseInt(readyTimeoutInput, 10) || 30) * 1e3;
7039970415
let config = await loadConfig(configPath);
7040070416
if (previewUrlInput) {
7040170417
config = { ...config, app: { ...config.app, previewUrl: previewUrlInput } };
@@ -70494,8 +70510,12 @@ async function run() {
7049470510
(0, import_node_child_process3.execFileSync)(parts[0], parts.slice(1), { stdio: "inherit" });
7049570511
}
7049670512
let appProcess = null;
70497-
if (config.app.startCommand && !config.app.previewUrl) {
70513+
if (config.app.startCommand && !config.app.previewUrl && !previewUrlInput) {
7049870514
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
70515+
} else if (config.app.previewUrl || previewUrlInput) {
70516+
core.info(`Waiting for preview URL to be ready: ${baseUrl}`);
70517+
await waitForUrl(baseUrl, readyTimeoutMs);
70518+
core.info("Preview URL is ready");
7049970519
}
7050070520
try {
7050170521
core.info("Running git-glimpse pipeline...");
@@ -70551,18 +70571,6 @@ async function startApp(startCommand, readyUrl) {
7055170571
core.info("App is ready");
7055270572
return proc;
7055370573
}
70554-
async function waitForUrl(url, timeout) {
70555-
const deadline = Date.now() + timeout;
70556-
while (Date.now() < deadline) {
70557-
try {
70558-
const res = await fetch(url);
70559-
if (res.ok) return;
70560-
} catch {
70561-
}
70562-
await new Promise((r2) => setTimeout(r2, 1e3));
70563-
}
70564-
throw new Error(`App did not become ready at ${url} within ${timeout / 1e3}s`);
70565-
}
7056670574
run().catch((err) => core.setFailed(err instanceof Error ? err.message : String(err)));
7056770575
/*! Bundled license information:
7056870576

packages/action/dist/index.js.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/action/src/index.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@git-glimpse/core';
1616
import { resolveBaseUrl } from './resolve-base-url.js';
1717
import { checkApiKey } from './api-key-check.js';
18+
import { waitForUrl } from './wait-for-url.js';
1819

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

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

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

183190
try {
@@ -246,18 +253,5 @@ async function startApp(
246253
return proc;
247254
}
248255

249-
async function waitForUrl(url: string, timeout: number): Promise<void> {
250-
const deadline = Date.now() + timeout;
251-
while (Date.now() < deadline) {
252-
try {
253-
const res = await fetch(url);
254-
if (res.ok) return;
255-
} catch {
256-
// not ready yet
257-
}
258-
await new Promise((r) => setTimeout(r, 1000));
259-
}
260-
throw new Error(`App did not become ready at ${url} within ${timeout / 1000}s`);
261-
}
262256

263257
run().catch((err) => core.setFailed(err instanceof Error ? err.message : String(err)));
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export async function waitForUrl(url: string, timeout: number): Promise<void> {
2+
const deadline = Date.now() + timeout;
3+
while (Date.now() < deadline) {
4+
try {
5+
const res = await fetch(url);
6+
if (res.ok) return;
7+
} catch {
8+
// not ready yet
9+
}
10+
await new Promise((r) => setTimeout(r, 1000));
11+
}
12+
throw new Error(`App did not become ready at ${url} within ${timeout / 1000}s`);
13+
}

tests/unit/wait-for-url.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { waitForUrl } from '../../packages/action/src/wait-for-url.js';
3+
4+
afterEach(() => {
5+
vi.restoreAllMocks();
6+
});
7+
8+
describe('waitForUrl', () => {
9+
it('resolves immediately when the URL returns ok on the first attempt', async () => {
10+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
11+
await expect(waitForUrl('http://example.com', 5000)).resolves.toBeUndefined();
12+
expect(fetch).toHaveBeenCalledTimes(1);
13+
});
14+
15+
it('retries until the URL becomes reachable', async () => {
16+
let calls = 0;
17+
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => {
18+
calls++;
19+
if (calls < 3) return { ok: false };
20+
return { ok: true };
21+
}));
22+
// Use a generous timeout; polling delay is mocked via fake timers below
23+
vi.useFakeTimers();
24+
const promise = waitForUrl('http://example.com', 10000);
25+
// Advance past two 1-second poll delays
26+
await vi.advanceTimersByTimeAsync(2000);
27+
vi.useRealTimers();
28+
await promise;
29+
expect(calls).toBe(3);
30+
});
31+
32+
it('throws when the URL never becomes reachable within the timeout', async () => {
33+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
34+
await expect(waitForUrl('http://example.com', 500)).rejects.toThrow(
35+
'did not become ready'
36+
);
37+
});
38+
});

0 commit comments

Comments
 (0)