Skip to content
Open
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
68 changes: 68 additions & 0 deletions .github/actions/record-simple-cli/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Record built Ghostwright CLI and Vim
description: Install the built tarball in an isolated consumer, test CLI and Vim journeys, and upload their recordings.
inputs:
package:
description: Absolute path to the built Ghostwright tarball
required: true
source-sha:
description: Commit used to build Ghostwright
required: true
outputs:
artifact-url:
description: Download URL for this run's recording
value: ${{ steps.upload.outputs.artifact-url }}
test-outcome:
description: Node test step outcome
value: ${{ steps.test.outcome }}
runs:
using: composite
steps:
- name: Prepare isolated consumer
id: prepare
shell: bash
env:
PACKAGE: ${{ inputs.package }}
run: |
set -euo pipefail
consumer="$RUNNER_TEMP/ghostwright-consumer"
mkdir -p "$consumer" "$RUNNER_TEMP/ghostwright-recordings"
cp "$GITHUB_WORKSPACE/experiments/ghostwright/ci/simple-cli/"* "$consumer/"
cp "$GITHUB_WORKSPACE/experiments/ghostwright/examples/vim-netrw/"{journey.mjs,netrw.ts} "$consumer/"
npm ci --prefix "$consumer" --ignore-scripts
npm install --prefix "$consumer" --ignore-scripts --no-save --package-lock=false "$PACKAGE"
- name: Test the packaged CLI and record
id: test
continue-on-error: true
shell: bash
env:
GHOSTWRIGHT_RECORD_DIR: ${{ runner.temp }}/ghostwright-recordings
GHOSTWRIGHT_SOURCE_SHA: ${{ inputs.source-sha }}
run: node --test "$RUNNER_TEMP/ghostwright-consumer/test.mjs" "$RUNNER_TEMP/ghostwright-consumer/vim.test.mjs"
- name: Upload recording and diagnostics
id: upload
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ghostwright-cli-vim-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ghostwright-recordings/
if-no-files-found: warn
retention-days: 14
- name: Summarize and preserve the test outcome
if: ${{ always() }}
shell: bash
env:
TEST_OUTCOME: ${{ steps.test.outcome }}
ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }}
SOURCE_SHA: ${{ inputs.source-sha }}
run: |
{
printf '### Ghostwright: built-package CLI and Vim recordings\n\n'
printf 'Source: `%s`\n\nTest: **%s**\n\n' "$SOURCE_SHA" "$TEST_OUTCOME"
if [ -n "$ARTIFACT_URL" ]; then
printf '[Download MP4, GIF, asciicast and trace](%s) (14-day retention)\n' "$ARTIFACT_URL"
else
printf 'No recording artifact was produced. See the job logs.\n'
fi
} >> "$GITHUB_STEP_SUMMARY"
test "$TEST_OUTCOME" = success
test -n "$ARTIFACT_URL"
198 changes: 198 additions & 0 deletions .github/workflows/ghostwright-recording.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
name: Ghostwright recording prototype

on:
pull_request:
workflow_dispatch:

permissions: {}

concurrency:
group: ghostwright-recording-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
record:
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
outputs:
artifact-url: ${{ steps.record.outputs.artifact-url }}
test-outcome: ${{ steps.record.outputs.test-outcome }}
source-sha: ${{ steps.source.outputs.sha }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Record the build revision
id: source
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24.22.0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.10
- name: Install build and recording tools
run: |
set -euo pipefail
npm install -g pnpm@10.24.0
sudo apt-get update -qq
sudo apt-get install -y musl-tools ncurses-bin ffmpeg fonts-dejavu-core vim
rustup toolchain install 1.90.0 --profile minimal --target x86_64-unknown-linux-musl
echo 'RUSTUP_TOOLCHAIN=1.90.0' >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/ghostwright-tools"
cd "$RUNNER_TEMP/ghostwright-tools"
curl --fail --location --retry 3 https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz -o zig.tar.xz
echo '02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 zig.tar.xz' | sha256sum -c -
tar -xf zig.tar.xz
echo "$RUNNER_TEMP/ghostwright-tools/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH"
curl --fail --location --retry 3 https://github.com/asciinema/agg/releases/download/v1.9.0/agg-x86_64-unknown-linux-musl -o agg
echo 'ddcbf6ca044c8ac3a434dcb9ee89fb9e3be87209982b7c2adb55f782e8f0f390 agg' | sha256sum -c -
chmod +x agg
echo "$RUNNER_TEMP/ghostwright-tools" >> "$GITHUB_PATH"
- name: Install Ghostwright dependencies only
run: pnpm --filter ghostwright... install --frozen-lockfile --ignore-scripts
- name: Build and verify native artifacts
working-directory: experiments/ghostwright
run: bun run setup
- name: Build and pack Ghostwright at this revision
working-directory: experiments/ghostwright
run: |
pnpm build
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP"
- name: Exercise the built package
id: record
uses: ./.github/actions/record-simple-cli
with:
package: ${{ runner.temp }}/ghostwright-0.1.0.tgz
source-sha: ${{ steps.source.outputs.sha }}

report:
needs: record
# This prototype posts only for same-repository PRs. Forks retain job artifacts.
if: ${{ always() && !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: write
actions: read
steps:
# Media is treated as data. No checkout or execution of PR code in this job.
- name: Download recording media
id: media
if: ${{ needs.record.outputs.artifact-url != '' }}
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ghostwright-cli-vim-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/recording-media
- name: Publish direct MP4 links
id: video
if: ${{ steps.media.outcome == 'success' }}
continue-on-error: true
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const fs = require('node:fs/promises');
const path = require('node:path');
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.head.sha !== context.payload.pull_request.head.sha) return;
const media = [];
for (const stem of ['simple-cli', 'vim-normal-skills']) {
const file = path.join(process.env.RUNNER_TEMP, 'recording-media', `${stem}.mp4`);
let stat;
try { stat = await fs.lstat(file); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
core.info(`No ${stem} MP4 was produced; see the Actions artifact.`);
continue;
}
if (!stat.isFile() || stat.size === 0 || stat.size > 25 * 1024 * 1024) {
throw new Error(`Expected ${stem} MP4 to be a nonempty file of at most 25 MiB`);
}
media.push({ stem, file, size: stat.size });
}
if (media.length === 0) return;
const tag = `ghostwright-recordings-pr-${number}`;
let release;
try {
({ data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag }));
} catch (error) {
if (error.status !== 404) throw error;
({ data: release } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, target_commitish: pr.head.sha,
name: `Ghostwright recordings — PR #${number}`,
body: `CI recording media for PR #${number}. Assets are named by run and attempt. This prerelease is not a package release. Media remains until this release is deleted.`,
prerelease: true, make_latest: 'false',
}));
}
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
owner, repo, release_id: release.id,
});
for (const { stem, file, size } of media) {
const name = `${stem}-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}.mp4`;
let asset = assets.find(asset => asset.name === name);
if (!asset) {
({ data: asset } = await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id: release.id, name,
data: await fs.readFile(file),
headers: { 'content-type': 'video/mp4', 'content-length': size },
}));
}
core.setOutput(stem, asset.browser_download_url);
}
- name: Update recording comment
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
MP4_URL: ${{ steps.video.outputs.simple-cli }}
VIM_URL: ${{ steps.video.outputs.vim-normal-skills }}
RECORDING_URL: ${{ needs.record.outputs.artifact-url }}
TEST_OUTCOME: ${{ needs.record.outputs.test-outcome }}
JOB_RESULT: ${{ needs.record.result }}
SOURCE_SHA: ${{ needs.record.outputs.source-sha }}
with:
script: |
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.head.sha !== context.payload.pull_request.head.sha) {
core.info('A newer commit superseded this recording; leaving its comment alone.');
return;
}
const marker = '<!-- ghostwright-recording-prototype:v1 -->';
const success = process.env.JOB_RESULT === 'success';
const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const body = [
marker,
'### Ghostwright CLI and Vim recordings',
'',
`${success ? '✅' : '❌'} **Build + packaged CLI/Vim tests: ${process.env.JOB_RESULT}**`,
`PR head: \`${pr.head.sha}\``,
`Built revision (PR merge commit): \`${process.env.SOURCE_SHA || 'setup failed'}\``,
`Test/encoding: **${process.env.TEST_OUTCOME || 'not run'}**`,
'',
process.env.MP4_URL
? `[Download CLI MP4](${process.env.MP4_URL})`
: 'CLI MP4 unavailable; check the artifact and publishing logs.',
process.env.VIM_URL
? `[Download Vim MP4](${process.env.VIM_URL})`
: 'Vim MP4 unavailable; check the artifact and publishing logs.',
'',
process.env.RECORDING_URL
? `[Download all recording files](${process.env.RECORDING_URL}) — MP4, GIF, asciicast and JSON trace. Available for 14 days.`
: 'No recording was produced. See the build/test logs.',
'',
`[View Actions run](${run})`,
'',
'These tests install the Ghostwright tarball built by this run and drive a real CLI and Vim/netrw through PTYs. No UI/TTY sibling checkouts are used.',
].join('\n');
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number });
const existing = comments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker));
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
else await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
- name: Preserve publishing failures
if: ${{ steps.media.outcome == 'failure' || steps.video.outcome == 'failure' }}
run: exit 1
62 changes: 62 additions & 0 deletions experiments/ghostwright/ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Playground PR recording prototype

`.github/workflows/ghostwright-recording.yml` builds the Ghostwright source at the
PR's checked-out merge commit, including native artifacts and the recording
plugin. It packs that build and invokes the local `record-simple-cli` action.
There are no sibling UI/TTY checkouts and no published Ghostwright dependency.

The action copies `simple-cli` into a fresh directory under RUNNER_TEMP, installs
the shared Vim journey (`examples/vim-netrw/journey.mjs` and `netrw.ts`),
its locked runtime dependency and the generated tarball, and runs Node's test
runner. The test imports `ghostwright` and `ghostwright/recording` through the
installed package exports and asserts those imports resolve into package `dist/`.
It launches a real shell CLI, types Ada, checks the greeting and process exit,
and records the session with the packaged plugin. A second Node test runs the
same Vim journey as `pnpm record:vim`: open netrw, locate `refactor.ts` from
painted screen geometry, edit with text objects, repeat, uppercase, yank/put,
increment, undo/redo, then save and exit. Both examples import the built package.
Run a package build before using the local Vim example after source changes.

MP4, intermediate GIF, asciicast, JSON frames and source metadata are uploaded
with 14-day retention, even after a test/encoder failure. A failed test still
fails the check. Setup failures before capture can produce no recording. The
Actions job summary and PR comment link to the artifact download. The bot also
links directly to both MP4s, published as `video/mp4` assets on a dedicated
`ghostwright-recordings-pr-<number>` prerelease. Each run/attempt gets a unique
filename. Release media remains until the release is deleted; the 14-day limit
applies only to Actions artifacts. The prerelease never becomes the latest release.

A direct release asset URL avoids downloading/unzipping the whole artifact.
GitHub's native inline comment player requires a GitHub video attachment;
release asset links are not native comment attachments. The supported release
API works with the workflow token and requires no browser session credentials.
Native inline attachments are deferred: the user-token attempt was rejected by
GitHub. The current workflow does not reference `GHOSTWRIGHT_ATTACHMENT_TOKEN`.

The separate report job checks out no code, downloads the current run's recording artifact as data,
publishes only `simple-cli.mp4` and `vim-normal-skills.mp4` (each limited to 25 MiB), and updates one bot
comment. It never executes downloaded files. Missing MP4s retain diagnostics;
publishing failures still update the comment and then fail the report job. Only same-repository PRs receive comments in this
prototype; forks and manual runs retain their Actions summaries/artifacts. The
report job verifies the PR head still matches the event before writing. It uses
pull-requests:write, actions:read and contents:write (for release uploads), while the build/test job only has contents:read. This is a
same-repository prototype, not the planned trusted-default-branch publisher for
arbitrary fork PRs.

Tool versions and download checksums are pinned in the workflow where practical.
Vim, FFmpeg, ncurses and fonts come from Ubuntu 24.04's package repositories. Native
artifacts are rebuilt from the pinned Ghostty source, patch and Rust lockfile;
the build refreshes artifact hashes in the packaged manifest and verifies its ABI.
No native build cache is used in this first prototype.

The consumer lock pins Effection 4.2.0, matching the scope API used by Ghostwright.
This isolated test caught that the former package minimum (4.0.2) and workspace
lock (4.0.3) were incompatible even though the linked development graph worked.

For a local run, build Ghostwright with `pnpm build` in its directory (after
building native artifacts), `npm pack` into a temporary directory, copy
`ci/simple-cli/*` and `examples/vim-netrw/{journey.mjs,netrw.ts}` into an empty
consumer directory, run `npm ci`, then
`npm install --no-save --package-lock=false /absolute/path/ghostwright-0.1.0.tgz`.
Set `GHOSTWRIGHT_RECORD_DIR` to a writable directory and run `node --test test.mjs vim.test.mjs`
from that consumer. Vim (with netrw), agg and FFmpeg must be on PATH. Use Node 24.22.0 as in CI.
22 changes: 22 additions & 0 deletions experiments/ghostwright/ci/simple-cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions experiments/ghostwright/ci/simple-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "ghostwright-ci-consumer",
"private": true,
"type": "module",
"dependencies": {
"effection": "4.2.0"
}
}
Loading
Loading