forked from aleqsio/screenmap
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaction.yml
More file actions
562 lines (542 loc) · 31.3 KB
/
Copy pathaction.yml
File metadata and controls
562 lines (542 loc) · 31.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# Marketplace metadata: must live at the repo root; a bare name would
# collide with AppLand's AppMap listing, hence the repo-matching name.
name: screenmap
description: >-
Map every screen of an Expo / React Native app and review what a pull request changes on-screen.
PR runs diff the head against a cached baseline map, capture only the affected screens on an iOS
simulator or Android emulator, and post a sticky comment linking a preloaded map viewer. Baseline
runs refresh the map of main incrementally and open a flows PR for screens the agent had to explore.
author: aleqsio
branding:
icon: map
color: blue
inputs:
mode:
description: "pr | baseline"
required: true
project:
description: Path to the Expo project (repo-relative)
default: "."
platform:
description: >-
ios (default, needs a macOS runner) or android (runs on ubuntu, which bills at a tenth of the
macOS rate). One platform per job: to get both in one map, run this action twice — iOS on
macos-latest, Android on ubuntu-latest — and fold the two bundles together with
`screenmap-ci merge --inputs ios=<a>.scrmap,android=<b>.scrmap`. See the workflow templates.
default: ios
agent_provider:
description: "Agent CLI for screens with no committed flow: claude (default) | codex | gemini | opencode. Or set agent.command in .screenmap/config.json to run any CLI."
default: ""
agent_api_key:
description: >-
API key for the chosen agent provider (mapped to the env var it expects). Optional: leave it
empty and effort falls back to "deterministic", which replays committed flows and deep-links
the rest without spending a token.
default: ""
effort:
description: >-
How hard to work, trading tokens and wall-clock against accuracy:
deterministic (no agent and no tokens — committed flows replay, everything else is
deep-linked unchecked), fast (the agent sees the screens no committed flow can reach —
cheapest agent lane, but a screen that starts needing a param quietly captures its own
not-found state), balanced (also re-checks routes whose deep link is a guess, which is
where silent bad captures come from), or thorough (every screen goes through the agent).
Defaults to balanced, or to deterministic when no agent key is set. Overridden by
anything set explicitly in .screenmap/config.json.
default: ""
agent_max_screens:
description: Cap on screens the agent may explore per run. Empty uses the effort preset's cap (fast 6, balanced 8, thorough 24; deterministic runs no agent at all).
default: ""
auto_baseline:
description: >-
(pr) When no baseline map exists, start the baseline workflow on the default branch instead of
asking the reader to run it. Needs actions: write on the PR workflow, and the baseline workflow
must have a workflow_dispatch trigger. Set false to only report the missing baseline.
default: "true"
baseline_workflow:
description: (pr) Filename of the baseline workflow to start when no baseline exists
default: screenmap-baseline.yml
screenmaps_branch:
description: Orphan branch that stores baseline maps and per-PR change bundles
default: screenmaps
publish:
description: "Publish bundles to the screenmaps branch so the comment can deep-link the hosted map viewer (requires contents: write). Set false for private repos if you only want the artifact."
default: "true"
viewer_url:
description: Hosted map viewer origin used in comment links
default: https://app.screenmap.dev
simulator:
description: (ios) Simulator device name to boot (falls back to any available iPhone)
default: iPhone 17 Pro
avd:
description: >-
(android) Name of the AVD to boot. When it does not exist the action creates it from
android_system_image. Ignored if a device or emulator is already attached to adb.
default: screenmap
android_system_image:
description: (android) System image for the AVD the action creates, in sdkmanager form
default: system-images;android-34;google_apis;x86_64
app_path:
description: >-
Path to a prebuilt dev client — an .app bundle for ios, an .apk for android. Bring your own
build — from a previous job's artifact, your own pipeline, or `eas build:run`-style downloads.
When set, EAS is not touched.
default: ""
expo_token:
description: EXPO_TOKEN for the built-in EAS lane — the dev client is fetched from EAS (newest finished build whose fingerprint matches the checkout) or built there when none matches. Required unless app_path is set.
default: ""
eas_profile:
description: >-
eas.json build profile for the dev client. Empty picks per platform: development-simulator for
ios (needs developmentClient + ios.simulator), development-emulator for android (needs
developmentClient + android.buildType "apk" — an .aab cannot be installed on an emulator).
default: ""
full:
description: >-
(baseline) Recapture every screen instead of reusing the unchanged ones from the previous
map. The baseline workflow template has exposed this as a workflow_dispatch input since the
beginning, but nothing ever passed it through, so "Rebuild the whole map" quietly did an
incremental run.
default: "false"
flows_pr:
description: "(baseline) Open a PR against the default branch with flows the agent recorded"
default: "true"
github_token:
description: Token for comments, screenmaps-branch pushes and flows PRs
default: ${{ github.token }}
outputs:
bundle:
description: Path of the produced .scrmap / .diff.scrmap
value: ${{ steps.run.outputs.bundle }}
summary:
description: Path of the run summary JSON
value: ${{ steps.run.outputs.summary }}
viewer_link:
description: Preloaded map viewer link (when published)
value: ${{ steps.publish.outputs.viewer_link }}
runs:
using: composite
steps:
- name: Check runner
shell: bash
run: |
case "${{ inputs.platform }}" in
ios|android) ;;
*) echo "::error::platform must be ios or android (got '${{ inputs.platform }}')"; exit 1 ;;
esac
if [ "${{ inputs.platform }}" = "ios" ] && [ "$(uname)" != "Darwin" ]; then
echo "::error::platform=ios needs a macOS runner (iOS simulator)"; exit 1
fi
if [ -z "${{ inputs.app_path }}${{ inputs.expo_token }}" ]; then
echo "::error::provide expo_token (EAS builds the dev client) or app_path (bring your own build)"; exit 1
fi
if [ "${{ inputs.platform }}" = "ios" ]; then
xcrun simctl list devices available | head -20
else
[ -n "$ANDROID_HOME$ANDROID_SDK_ROOT" ] || { echo "::error::no Android SDK on this runner (ANDROID_HOME unset)"; exit 1; }
echo "Android SDK: ${ANDROID_HOME:-$ANDROID_SDK_ROOT}"
fi
# Two things Linux needs that macOS brings in the box.
#
# OCR: screen text drives the landing checks, deep-link verification and
# system-alert dismissal. macOS uses Apple Vision (compiled on demand by
# lib/ocr.mjs); Linux has no Vision, so without tesseract every one of those
# checks silently goes dark and a drifted flow stops being detectable.
#
# libpulse0: the SDK's qemu binary links against it and GitHub's ubuntu
# images do not carry it, so `emulator` cannot start at all — it fails with
# "libpulse.so.0: cannot open shared object file". The rest are the X libs
# different system images pull in; they are installed one at a time and
# best-effort because the names drift between ubuntu releases (libasound2 vs
# libasound2t64) and one missing optional package should not fail the run.
- name: Install Linux dependencies
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq tesseract-ocr imagemagick
if [ "${{ inputs.platform }}" = "android" ]; then
sudo apt-get install -y -qq libpulse0
for pkg in libnss3 libxcursor1 libxdamage1 libxcomposite1 libxi6 libxtst6 libasound2t64 libasound2; do
sudo apt-get install -y -qq "$pkg" 2>/dev/null || true
done
fi
- name: Install screenmap-ci
shell: bash
run: |
cd "${{ github.action_path }}/action/cli" && npm ci --silent || npm install --silent
echo "${{ github.action_path }}/action/cli" >> "$GITHUB_PATH"
ln -sfn "${{ github.action_path }}/action/cli/screenmap-ci.mjs" "${{ github.action_path }}/action/cli/screenmap-ci"
npm install -g @swmansion/argent@0.21.0 --silent # headless flow replay
# Claim the sticky comment before the slow part, so the PR says a map is
# coming instead of sitting silent for ~12 minutes. Every later state
# rewrites this same comment rather than adding another.
- name: Say the map is coming
if: inputs.mode == 'pr' && github.event.pull_request.number
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
screenmap-ci status --state pending --post \
--repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || true
- name: Install project dependencies
shell: bash
working-directory: ${{ inputs.project }}
run: |
if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile
else npm ci; fi
- name: Boot simulator (ios)
if: inputs.platform == 'ios'
shell: bash
run: |
udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; ds=[x for v in d.values() for x in v]; m=[x for x in ds if x["name"]=="${{ inputs.simulator }}"] or [x for x in ds if "iPhone" in x["name"]]; print(m[0]["udid"])')
xcrun simctl boot "$udid" || true
xcrun simctl bootstatus "$udid" -b
echo "SIM_UDID=$udid" >> "$GITHUB_ENV"
# KVM turns a ~40-minute software-rendered emulator boot into a ~2-minute one.
# It is the difference between Android being cheaper than macOS and not.
- name: Enable KVM (android on Linux)
if: inputs.platform == 'android' && runner.os == 'Linux'
shell: bash
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules >/dev/null
sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
kvm-ok 2>/dev/null || echo "note: kvm-ok unavailable; continuing"
- name: Boot emulator (android)
if: inputs.platform == 'android'
shell: bash
run: |
set -e
sdk="${ANDROID_HOME:-$ANDROID_SDK_ROOT}"
export PATH="$sdk/platform-tools:$sdk/emulator:$sdk/cmdline-tools/latest/bin:$PATH"
echo "$sdk/platform-tools" >> "$GITHUB_PATH"
echo "$sdk/emulator" >> "$GITHUB_PATH"
command -v sdkmanager >/dev/null || { echo "::error::sdkmanager not found under $sdk/cmdline-tools/latest/bin"; exit 1; }
# Pin where AVDs live. avdmanager and emulator each resolve this from a
# different chain ($ANDROID_AVD_HOME, then $ANDROID_SDK_HOME/.android/avd
# for one and $HOME/.android/avd for the other), so on a runner that sets
# ANDROID_SDK_HOME they write and read different directories: the AVD is
# created successfully and `emulator -list-avds` still comes back empty.
# Setting it explicitly makes both agree, and GITHUB_ENV carries it to
# the step that actually boots the device.
export ANDROID_AVD_HOME="$HOME/.android/avd"
mkdir -p "$ANDROID_AVD_HOME"
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
# a device already attached (self-hosted runner, or an earlier step) wins,
# and needs none of the SDK packages below
if command -v adb >/dev/null && adb devices | awk 'NR>1 && $2=="device"' | grep -q .; then
echo "using the attached device: $(adb devices | awk 'NR>1 && $2=="device" {print $1}' | head -1)"
else
# Install what we actually use rather than trusting the runner image to
# carry it: GitHub's ubuntu images ship platform-tools, but `emulator`
# is not guaranteed, and a missing one surfaces as a bare
# "command not found" three steps later. Already-present packages are
# a no-op, so this costs nothing on a warm image.
#
# `yes |` needs care: GitHub runs `shell: bash` with -eo pipefail, and
# `yes` is killed by SIGPIPE the moment sdkmanager stops reading, so
# pipefail reports the whole pipeline as failed even on a clean
# install. Take sdkmanager's own status out of PIPESTATUS instead.
set +o pipefail
yes 2>/dev/null | sdkmanager --install "platform-tools" "emulator" "${{ inputs.android_system_image }}" >/tmp/sdkmanager.log 2>&1
rc=${PIPESTATUS[1]}
set -o pipefail
if [ "$rc" -ne 0 ]; then
tail -30 /tmp/sdkmanager.log
echo "::error::sdkmanager failed to install platform-tools/emulator/${{ inputs.android_system_image }}"
exit 1
fi
command -v avdmanager >/dev/null || { echo "::error::avdmanager not found under $sdk/cmdline-tools/latest/bin"; exit 1; }
if ! avdmanager list avd -c | grep -qx "${{ inputs.avd }}"; then
echo no | avdmanager create avd -n "${{ inputs.avd }}" -k "${{ inputs.android_system_image }}" --force
fi
# the driver (lib/android.mjs) boots it and waits for sys.boot_completed;
# starting it here would just duplicate that logic
# Gate on the emulator actually running. This used to be `|| true`,
# which hid a qemu that could not load libpulse: the run went on to
# spend seventeen minutes on an EAS build and only then failed, with
# "no AVD defined" — the one thing that was not wrong. Fail here, where
# the message can name the real cause.
if ! "$sdk/emulator/emulator" -version >/tmp/emulator-version.log 2>&1; then
tail -5 /tmp/emulator-version.log
echo "::error::the emulator binary cannot run on this runner (see above — a missing shared library is the usual cause)"
exit 1
fi
head -1 /tmp/emulator-version.log
# …and on it agreeing that the AVD exists. Creating one successfully
# and having the emulator not see it is the failure this step is most
# prone to, and one the next step can only report as "no AVD defined".
if ! "$sdk/emulator/emulator" -list-avds | grep -qx "${{ inputs.avd }}"; then
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME"
ls -la "$ANDROID_AVD_HOME" 2>&1 | head -20
echo "emulator -list-avds says: $("$sdk/emulator/emulator" -list-avds 2>&1 | tr '\n' ' ')"
echo "::error::AVD '${{ inputs.avd }}' was created but the emulator cannot see it"
exit 1
fi
echo "AVD ${{ inputs.avd }} ready — the run boots it"
fi
- name: Dev client (EAS or provided)
id: app
shell: bash
env:
EXPO_TOKEN: ${{ inputs.expo_token }}
run: |
set -e
if [ -n "${{ inputs.app_path }}" ]; then
app="$(cd "$(dirname "${{ inputs.app_path }}")" && pwd)/$(basename "${{ inputs.app_path }}")"
# an iOS dev client is a .app directory; an Android one is an .apk file
[ -e "$app" ] || { echo "::error::app_path not found: $app"; exit 1; }
echo "using provided dev client: $app"
else
command -v eas >/dev/null || npm install -g eas-cli --silent
profile="${{ inputs.eas_profile }}"
if [ -z "$profile" ]; then
if [ "${{ inputs.platform }}" = "android" ]; then profile=development-emulator; else profile=development-simulator; fi
fi
screenmap-ci resolve-app --project "${{ inputs.project }}" --platform "${{ inputs.platform }}" --profile "$profile" | tee /tmp/screenmap-app.json
app=$(python3 -c 'import json,sys;print(json.load(open("/tmp/screenmap-app.json"))["appPath"])')
fi
echo "app=$app" >> "$GITHUB_OUTPUT"
- name: Restore baseline map
id: baseline
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
set -e
mkdir -p .screenmap-ci
# prefer the baseline published for the PR base SHA; fall back to latest
base_sha="${{ github.event.pull_request.base.sha }}"
branch="${{ inputs.screenmaps_branch }}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
# --force: this is a remote-tracking ref, and the screenmaps branch is
# advanced by every concurrent PR run. Without it a run that started
# before another run published dies here on a non-fast-forward.
git fetch --depth 1 --force origin "$branch":refs/remotes/origin/"$branch"
pick=""
[ -n "$base_sha" ] && pick=$(git ls-tree --name-only -r origin/$branch | grep "^main/${base_sha:0:7}" | head -1 || true)
[ -z "$pick" ] && pick=$(git ls-tree --name-only -r origin/$branch | grep '^main/latest.scrmap$' | head -1 || true)
if [ -n "$pick" ]; then git show "origin/$branch:$pick" > .screenmap-ci/baseline.scrmap; echo "found=true" >> "$GITHUB_OUTPUT"; echo "baseline: $pick"; fi
fi
[ -f .screenmap-ci/baseline.scrmap ] || echo "found=false" >> "$GITHUB_OUTPUT"
- name: Run screenmap-ci
id: run
shell: bash
env:
AGENT_PROVIDER: ${{ inputs.agent_provider }}
SCREENMAP_EFFORT: ${{ inputs.effort }}
AGENT_MAX_SCREENS: ${{ inputs.agent_max_screens }}
AGENT_API_KEY: ${{ inputs.agent_api_key }}
SCREENMAP_APP_PATH: ${{ steps.app.outputs.app }}
SCREENMAP_PLATFORMS: ${{ inputs.platform }}
# the device this action actually provisioned, so the driver boots that
# one rather than whichever happens to be listed first
SCREENMAP_DEVICE_IOS: ${{ inputs.simulator }}
SCREENMAP_DEVICE_ANDROID: ${{ inputs.avd }}
GH_TOKEN: ${{ inputs.github_token }}
run: |
set -e
# install the chosen agent CLI on demand; a custom agent.command must be preinstalled by the workflow
if [ -n "$AGENT_API_KEY$AGENT_PROVIDER" ]; then
case "${AGENT_PROVIDER:-claude}" in
claude) bin=claude; pkg=@anthropic-ai/claude-code ;;
codex) bin=codex; pkg=@openai/codex ;;
gemini) bin=gemini; pkg=@google/gemini-cli ;;
opencode) bin=opencode; pkg=opencode-ai ;;
*) bin=""; pkg="" ;;
esac
if [ -n "$bin" ] && ! command -v "$bin" >/dev/null; then npm install -g "$pkg" --silent; fi
fi
proj="${{ inputs.project }}"
mkdir -p "$proj/.screenmap" && [ -f "$proj/.screenmap/config.json" ] || true
if [ "${{ inputs.mode }}" = "pr" ]; then
if [ "${{ steps.baseline.outputs.found }}" != "true" ]; then
# The baseline is a prerequisite we can satisfy ourselves. Start it
# on the default branch rather than telling the reader to go and
# click it. We dispatch instead of mapping inline: a full map is
# ~20 minutes, and waiting for it here would bill that at the macOS
# rate while this job sits idle. The baseline job re-runs open PRs
# when it finishes, so the loop closes without anyone pushing.
started=""
if [ "${{ inputs.auto_baseline }}" = "true" ]; then
wf="${{ inputs.baseline_workflow }}"
branch="${{ github.event.repository.default_branch }}"
# don't stack dispatches if one is already on its way
running=$(gh run list --repo "${{ github.repository }}" --workflow "$wf" \
--status in_progress --json databaseId -q 'length' 2>/dev/null || echo 0)
queued=$(gh run list --repo "${{ github.repository }}" --workflow "$wf" \
--status queued --json databaseId -q 'length' 2>/dev/null || echo 0)
# A baseline that already finished and still left us without a map
# means dispatching again would just repeat: PR re-runs, finds
# nothing, dispatches, baseline finishes, PR re-runs. Bail out and
# say so rather than spending macOS minutes in a circle.
recent=$(gh run list --repo "${{ github.repository }}" --workflow "$wf" \
--status success --limit 1 --json updatedAt \
-q '.[0].updatedAt // ""' 2>/dev/null || echo "")
settled=0
if [ -n "$recent" ]; then
r_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$recent" +%s 2>/dev/null || echo 0)
now=$(date +%s)
[ "$r_epoch" -gt 0 ] && [ $((now - r_epoch)) -lt 5400 ] && settled=1
fi
if [ "$settled" = "1" ]; then
echo "::warning::a baseline completed recently but no map was found on $branch — not dispatching again"
started=""
elif [ "$running" = "0" ] && [ "$queued" = "0" ]; then
if gh workflow run "$wf" --repo "${{ github.repository }}" --ref "$branch" 2>/dev/null; then
started="yes"; echo "dispatched $wf on $branch"
else
echo "::warning::could not start $wf automatically — the workflow needs actions: write, and $wf must have a workflow_dispatch trigger"
fi
else
started="yes"; echo "$wf is already running"
fi
fi
if [ -n "$started" ]; then
sleep 4 # let the run register so the link resolves
url=$(gh run list --repo "${{ github.repository }}" --workflow "${{ inputs.baseline_workflow }}" --limit 1 --json url -q '.[0].url' 2>/dev/null || echo "")
screenmap-ci status --state baseline-started --post \
--repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \
${url:+--run-url "$url"} || true
else
echo "::warning::no baseline map found on ${{ inputs.screenmaps_branch }} — run the baseline workflow on the default branch first"
# say so on the PR: a green job that posts nothing reads as "no screens changed"
screenmap-ci status --state no-baseline --post \
--repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \
--repo-url "${{ github.server_url }}/${{ github.repository }}" \
--branch "${{ inputs.screenmaps_branch }}" || true
fi
echo "bundle=" >> "$GITHUB_OUTPUT"; exit 0
fi
git fetch --depth 1 origin "${{ github.event.pull_request.base.sha }}" || true
screenmap-ci pr --project "$proj" --baseline .screenmap-ci/baseline.scrmap \
--base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}" \
--pr "${{ github.event.pull_request.number }}" --title "${{ github.event.pull_request.title }}" --url "${{ github.event.pull_request.html_url }}" \
--base-ref "${{ github.event.pull_request.base.ref }}" --head-ref "${{ github.event.pull_request.head.ref }}" > .screenmap-ci/summary.json
else
screenmap-ci baseline --project "$proj" \
$( [ "${{ steps.baseline.outputs.found }}" = "true" ] && echo "--previous .screenmap-ci/baseline.scrmap" ) \
$( [ "${{ inputs.full }}" = "true" ] && echo "--full" ) > .screenmap-ci/summary.json
fi
bundle=$(python3 -c 'import json;print(json.load(open(".screenmap-ci/summary.json"))["bundle"])')
echo "bundle=$bundle" >> "$GITHUB_OUTPUT"
echo "summary=.screenmap-ci/summary.json" >> "$GITHUB_OUTPUT"
- name: Upload diagnostics (failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenmap-diagnostics-${{ github.run_id }}
path: ${{ inputs.project }}/.screenmap/out/ci/diag
retention-days: 7
if-no-files-found: ignore
- name: Upload artifact
if: steps.run.outputs.bundle != ''
uses: actions/upload-artifact@v4
with:
name: screenmap-${{ inputs.mode }}-${{ github.run_id }}
path: |
${{ steps.run.outputs.bundle }}
.screenmap-ci/summary.json
retention-days: 90
- name: Publish to screenmaps branch
id: publish
if: steps.run.outputs.bundle != '' && inputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
set -e
bundle="${{ steps.run.outputs.bundle }}"
if [ "${{ inputs.mode }}" = "pr" ]; then
dest="pr-${{ github.event.pull_request.number }}/${{ github.event.pull_request.head.sha }}.diff.scrmap"
files="$bundle=$dest"
# the captures themselves, so the comment can show the screens rather
# than describe them; paths mirror the summary's `shots` entries
shots_dir="pr-${{ github.event.pull_request.number }}/${{ github.event.pull_request.head.sha }}"
diff_dir="${{ inputs.project }}/.screenmap/out/ci/pr/diff"
for side in head base; do
[ -d "$diff_dir/$side/screens" ] && files="$files,$diff_dir/$side/screens=$shots_dir/$side/screens"
done
out=$(screenmap-ci publish --repo "${{ github.repository }}" --branch "${{ inputs.screenmaps_branch }}" --files "$files" --message "pr #${{ github.event.pull_request.number }} @ ${{ github.event.pull_request.head.sha }}")
changes_url=$(echo "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['urls']['$dest'])")
shots_base=$(echo "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['base'])")/$shots_dir
echo "shots_base=$shots_base" >> "$GITHUB_OUTPUT"
# the baseline this diff was computed against, so the viewer gets the backdrop
base_sha="${{ github.event.pull_request.base.sha }}"
map_url="https://raw.githubusercontent.com/${{ github.repository }}/$(git rev-parse origin/${{ inputs.screenmaps_branch }})/main/${base_sha:0:7}.scrmap"
git ls-tree --name-only -r origin/${{ inputs.screenmaps_branch }} | grep -q "^main/${base_sha:0:7}.scrmap$" || map_url="https://raw.githubusercontent.com/${{ github.repository }}/$(git rev-parse origin/${{ inputs.screenmaps_branch }})/main/latest.scrmap"
echo "changes_url=$changes_url" >> "$GITHUB_OUTPUT"; echo "map_url=$map_url" >> "$GITHUB_OUTPUT"
echo "viewer_link=${{ inputs.viewer_url }}/?map=$map_url&changes=$changes_url" >> "$GITHUB_OUTPUT"
else
sha="${{ github.sha }}"
screenmap-ci publish --repo "${{ github.repository }}" --branch "${{ inputs.screenmaps_branch }}" --files "$bundle=main/${sha:0:7}.scrmap,$bundle=main/latest.scrmap" --message "baseline @ $sha" >/dev/null
echo "viewer_link=${{ inputs.viewer_url }}/?map=https://raw.githubusercontent.com/${{ github.repository }}/$(git rev-parse origin/${{ inputs.screenmaps_branch }})/main/latest.scrmap" >> "$GITHUB_OUTPUT"
fi
- name: Comment on PR
if: inputs.mode == 'pr' && steps.run.outputs.bundle != ''
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
artifact_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts"
screenmap-ci comment --summary .screenmap-ci/summary.json --post --repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \
--viewer "${{ inputs.viewer_url }}" --artifact-url "$artifact_url" \
$( [ -n "${{ steps.publish.outputs.map_url }}" ] && echo --map-url "${{ steps.publish.outputs.map_url }}" ) \
$( [ -n "${{ steps.publish.outputs.changes_url }}" ] && echo --changes-url "${{ steps.publish.outputs.changes_url }}" ) \
$( [ -n "${{ steps.publish.outputs.shots_base }}" ] && echo --shots-base "${{ steps.publish.outputs.shots_base }}" )
- name: Open flows PR
if: inputs.mode == 'baseline' && inputs.flows_pr == 'true' && steps.run.outputs.bundle != ''
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
dir=$(python3 -c 'import json;print(json.load(open(".screenmap-ci/summary.json")).get("recordedFlowsDir") or "")')
if [ -n "$dir" ]; then
screenmap-ci flows-pr --repo "${{ github.repository }}" --flows "$dir" --base "${{ github.event.repository.default_branch }}" \
--title "screenmap: record flows for new screens" \
--body "Flows recorded by the screenmap agent during the baseline refresh for screens that had no committed flow. Review the taps, then merge — future runs replay them deterministically (no LLM)."
else echo "no new flows recorded"; fi
# The other half of auto_baseline: a PR that stopped for a missing baseline
# is waiting on this job, so re-run it rather than making someone push an
# empty commit. Only PRs whose latest screenmap run produced no bundle are
# touched, so ordinary PRs are left alone.
# The other half of auto_baseline: a PR that stopped for a missing baseline
# is waiting on this job, so re-run it rather than making someone push an
# empty commit.
#
# The signal is the PR's own sticky comment, not the run log. GitHub echoes
# every composite step's script source into the log, so grepping it for
# "no baseline map found" matches the echoed source on every run and would
# re-run every open PR in the repo.
- name: Re-run PRs that were waiting for this baseline
if: inputs.mode == 'baseline' && steps.run.outputs.bundle != ''
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
repo="${{ github.repository }}"
for pr in $(gh pr list --repo "$repo" --state open --json number -q '.[].number' 2>/dev/null); do
body=$(gh api "repos/$repo/issues/$pr/comments" -q '.[] | select(.body | contains("<!-- screenmap-ci -->")) | .body' 2>/dev/null | tail -1)
case "$body" in
*"no baseline map yet"*|*"building the first map"*) ;;
*) continue ;;
esac
branch=$(gh pr view "$pr" --repo "$repo" --json headRefName -q .headRefName 2>/dev/null || true)
[ -n "$branch" ] || continue
run=$(gh run list --repo "$repo" --branch "$branch" --event pull_request --limit 20 \
--json databaseId,workflowName \
-q "[.[] | select(.workflowName | test(\"screenmap\"; \"i\"))] | .[0].databaseId" 2>/dev/null || true)
[ -n "$run" ] && [ "$run" != "null" ] || continue
gh run rerun "$run" --repo "$repo" 2>/dev/null && echo "re-ran PR #$pr ($run)" || echo "could not re-run PR #$pr"
done
- name: Replace the pending comment if the run failed
if: failure() && inputs.mode == 'pr' && github.event.pull_request.number
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
run: |
screenmap-ci status --state failed --post \
--repo "${{ github.repository }}" --pr "${{ github.event.pull_request.number }}" \
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || true