Skip to content

feat(actions): add a record composite action - #28

Merged
kevinccbsg merged 1 commit into
mainfrom
feat/record-action
Sep 7, 2026
Merged

kevinccbsg merged 1 commit into
mainfrom
feat/record-action

Conversation

@kevinccbsg

Copy link
Copy Markdown
Member

Implements docs/spec-record-action.md.

- uses: BRIKEV/twd-cli/.github/actions/record@v1
  with:
    changed-since: ${{ github.event.pull_request.base.sha }}

The preconditions are met

The spec said do not build this first — packaging the workarounds would distribute them as public input surface that has to be deprecated rather than deleted. All four are now done:

So this is the four-step action it was meant to be, not the ten-step one.

Two decisions worth reviewing

It installs ffmpeg 8.x from BtbN's n8.1 build. Not apt, and not "the obvious static build" — my #24 measurements killed that assumption. Puppeteer passes -movflags hybrid_fragmented, which arrived after ffmpeg 7:

ffmpeg works
6.1.1 (ubuntu-24.04, what apt gives you) no
7.0.2 (johnvansickle release) no
8.1.x yes

The gpl variant also carries libx264 for the H.264 conversion, so one download covers both requirements. The URL is rolling and therefore unchecksummable — acceptable here because the CLI now probes the binary's real capability before launching a browser, so a bad build fails fast with an actionable message instead of producing a broken clip. Linux-only; other runners get a warning and skip.

The action deliberately does not verify that capability itself — that stays in the CLI preflight so every user gets it, not only Actions users.

clip-count: 0 is a success. A branch that changed no tests has nothing to record, so the upload step is skipped at zero rather than running with if-no-files-found: error and failing the job on a normal outcome.

Verification

There is nothing here for vitest — it's YAML. So I extracted each step body from the file itself and ran it against fixtures:

what result
titles with spaces, "quotes" and an apostrophe each arrives as one argument
a title of $(touch /tmp/pwned); rm -rf / arrives as literal text; nothing executed
CRLF-terminated tests input \r stripped
pace: 0 survives (would have been dropped before #27)
blank lines in tests skipped
2 mp4 + a .txt + a nested mp4 counts 2
missing dir / unresolved dir / only non-video files counts 0
.webm and .gif counted
config absent / without record.dir / unparseable falls back to ./twd-artifacts
tests + changed-since together ::error:: and exit 1

actionlint can only parse workflow files, not composite actions, so it gave no signal here — the if: expressions mirror the shapes already proven in .github/actions/run.

What is not verified, and what I did not do

The assembled action has never run on a runner. I considered adding an e2e-record job, but test-example-app does not depend on twd-cli — the e2e workflow calls node ../bin/twd-cli.js — so the action's npx twd-cli would exercise the published CLI rather than this branch. Making that meaningful needs npm-link plumbing, and putting a real recording on every PR adds cost and a flake source to this repo's CI.

The honest end-to-end proof is the spec's last acceptance criterion: rewriting twd-vue-example's record.yml onto the action so it loses the five workaround steps and both scripts. That is a different repo and needs a merged ref to point at, so it is the natural follow-up — happy to do it next.

Also

  • Workflow policy — trigger, PR comment, dev server, timeout-minutes, continue-on-error — stays with the caller, as it does for run. The reference workflow in the README carries all of it, including the timeout and continue-on-error your notes asked to keep as belt.
  • clip-count will be 0 or 1 until one-clip-per-test lands; it counts files rather than assuming, so it becomes correct for free when that ships.
  • README gains the action's inputs, outputs, the ffmpeg rationale and the reference workflow. CLAUDE.md gains a section on the conventions both actions share.
  • 561 tests passing (unchanged — no src/ code in this PR).

🤖 Generated with Claude Code

Every consumer that wants clips in CI re-implements the same recipe.
twd-vue-example's record.yml is 13 steps, five of which plus two files exist only
to work around twd-cli defects. Those defects are now fixed, which is what makes
this action worth building: packaging the workarounds would have distributed
them as public input surface that then has to be deprecated rather than deleted.

The preconditions the spec set are all met — a destroyed ffmpeg stream aborts the
run with its stderr surfaced (#24), the output is playable outside Chrome (#24),
the preflight checks capability rather than existence (#24), and --changed-since
exists (#25). So the action is the four steps it was supposed to be:

    - uses: BRIKEV/twd-cli/.github/actions/record@v1
      with:
        changed-since: ${{ github.event.pull_request.base.sha }}

Notes on the two decisions that are not obvious from the spec:

It installs ffmpeg 8.x from BtbN's n8.1 build, not the distro package and not
"the obvious static build". Puppeteer passes -movflags hybrid_fragmented, which
arrived after ffmpeg 7: 6.1.1 (ubuntu-24.04) no, 7.0.2 (johnvansickle release)
no, 8.1.2 yes. The gpl variant also carries libx264 for the H.264 conversion, so
one download covers both requirements. The URL is rolling and unchecksummable,
which is acceptable because the CLI now probes the binary's real capability
before launching a browser — an unusable build fails fast and says why.

The action deliberately does not verify that capability itself. That belongs in
the CLI preflight so every user gets it, not only Actions users.

clip-count 0 is a success. A branch that changed no tests has nothing to record,
so the upload step is skipped at zero rather than running with
if-no-files-found: error and failing the job on a normal outcome.

Workflow policy — the trigger, the PR comment, the dev server, timeout-minutes
and continue-on-error — stays with the caller, exactly as it does for the `run`
action. The reference workflow in the README shows all of it.

Verified by extracting each step body from the YAML and running it against
fixtures: argument construction (titles with spaces, quotes and apostrophes each
arrive as one argument; a shell-injection attempt arrives as literal text and
does not execute; CRLF input is stripped; --record-pace 0 survives), clip
counting (nested and non-video files excluded, missing and unresolved
directories give 0), directory resolution (absent, partial and unparseable
configs all fall back), and input validation.

Not verified: the assembled action has never run on a runner. test-example-app
does not depend on twd-cli — the e2e workflow calls node ../bin/twd-cli.js — so
an e2e job here would exercise the published CLI rather than this branch. The
real proof is rewriting twd-vue-example's record.yml onto the action, which is a
separate repo and a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

TWD Contract Validation

Spec Passed Failed Warnings Mode
./contracts/users-3.0.json 2 3 1 warn
./contracts/posts-3.1.json 2 2 0 warn
./contracts/products-3.0.json 13 23 2 warn
./contracts/events-3.1.json 6 13 0 warn

23 passed · 41 failed · 3 warnings · 1 skipped

Failed validations

./contracts/users-3.0.json

  • GET /users/{userId} (200) — mock getUserNoAddress — in "Contract Validation - Mismatches > should fail: missing nested address field"
    • response.address: missing required property "address"
  • GET /users/{userId} (200) — mock getUserBadAddress — in "Contract Validation - Mismatches > should fail: nested address missing required city"
    • response.address.city: missing required property "city"
    • response.address.country: missing required property "country"
  • GET /users/{userId} (200) — mock getUserBadRole — in "Contract Validation - Mismatches > should fail: oneOf role with invalid variant"
    • response.role: oneOf best match (branch 2 of 2) failed: must be one of: "viewer"

./contracts/posts-3.1.json

  • GET /posts/{postId} (200) — mock getPostNoAuthor — in "Contract Validation - Mismatches > should fail: post missing nested author object"
    • response.author: missing required property "author"
  • GET /posts/{postId} (200) — mock getPostBadMeta — in "Contract Validation - Mismatches > should fail: post oneOf metadata matches neither variant"
    • response.metadata: oneOf best match (branch 1 of 2) failed: missing required property "category", unexpected property "duration", must be one of: "article"

./contracts/products-3.0.json

  • GET /products (200) — mock getProductEmptyName — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: empty name violates minLength"
    • response[0].name: must NOT have fewer than 1 characters
  • GET /products (200) — mock getProductBadSku — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid SKU pattern"
    • response[0].sku: must match pattern "^[A-Z]{2,4}-\d{4,8}$"
  • GET /products (200) — mock getProductBadUuid — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid uuid format for id"
    • response[0].id: must match format "uuid"
  • GET /products (200) — mock getProductBadDateTime — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid date-time format"
    • response[0].createdAt: must match format "date-time"
  • GET /products (200) — mock getProductBadDate — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid date format"
    • response[0].releaseDate: must match format "date"
  • GET /products (200) — mock getProductBadEmail — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid email format"
    • response[0].contactEmail: must match format "email"
  • GET /products (200) — mock getProductBadUri — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid uri format"
    • response[0].website: must match format "uri"
  • GET /products (200) — mock getProductBadIp — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid ipv4 format"
    • response[0].serverIp: must match format "ipv4"
  • GET /products (200) — mock getProductBadIpV6 — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid ipv6 format"
    • response[0].serverIpV6: must match format "ipv6"
  • GET /products (200) — mock getProductZeroPrice — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: price of 0 violates exclusiveMinimum"
    • response[0].price: must be > 0
  • GET /products (200) — mock getProductNegQty — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: negative quantity violates minimum"
    • response[0].quantity: must be >= 0
  • GET /products (200) — mock getProductOverQty — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: quantity exceeds maximum"
    • response[0].quantity: must be <= 999999
  • GET /products (200) — mock getProductBadWeight — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: weight not multipleOf 0.01"
    • response[0].weight: must be multiple of 0.01
  • GET /products (200) — mock getProductBadRating — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: rating above maximum (5)"
    • response[0].rating: must be <= 5
  • GET /products (200) — mock getProductBadCurrency — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid enum value for currency"
    • response[0].currency: must be one of: "USD", "EUR", "GBP", "JPY"
  • GET /products (200) — mock getProductBadCategory — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid enum value for category"
    • response[0].category: must be one of: "electronics", "clothing", "food", "books", "toys"
  • GET /products (200) — mock getProductBadBool — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: string value for boolean inStock"
    • response[0].inStock: expected boolean, got string
  • GET /products (200) — mock getProductDupTags — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: duplicate tags violates uniqueItems"
    • response[0].tags: must NOT have duplicate items (items ## 1 and 0 are identical)
  • GET /products (200) — mock getProductTooManyTags — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: tags exceeds maxItems (10)"
    • response[0].tags: must NOT have more than 10 items
  • GET /products (200) — mock getProductBadMeta — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: non-string value in metadata additionalProperties"
    • response[0].metadata.count: expected string, got number
  • GET /settings (200) — mock getSettingsBadExtra — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: extra property on Settings (additionalProperties: false)"
    • response.extraField: unexpected property "extraField"
  • GET /settings (200) — mock getSettingsBadLang — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: invalid language pattern in Settings"
    • response.language: must match pattern "^[a-z]{2}(-[A-Z]{2})?$"
  • GET /products (200) — mock getProductBadNullable — in "Contract Validation - Products Mismatches (OpenAPI 3.0 — error mode) > should fail: wrong type for nullable description (number instead of string|null)"
    • response[0].description: expected string,null, got number

./contracts/events-3.1.json

  • GET /events (200) — mock getEventsEmpty — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: empty events array violates minItems (1)"
    • response: must NOT have fewer than 1 items
  • GET /events (200) — mock getEventShortName — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: event name too short (minLength: 3)"
    • response[0].name: must NOT have fewer than 3 characters
  • GET /events (200) — mock getEventBadDate — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid date-time format for startDate"
    • response[0].startDate: must match format "date-time"
  • GET /events (200) — mock getEventFloatId — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: float value for integer id"
    • response[0].id: expected integer, got number
    • response[0].id: must match format "int64"
  • GET /events (200) — mock getEventBadBool — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: number value for boolean active"
    • response[0].active: expected boolean, got number
  • GET /events (200) — mock getEventBadStatus — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid enum value for status"
    • response[0].status: must be one of: "draft", "published", "archived"
  • GET /events (200) — mock getEventScoreMax — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: score at exclusiveMaximum boundary (100)"
    • response[0].score: must be < 100
  • GET /events (200) — mock getEventLowPriority — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: priority below minimum (1)"
    • response[0].priority: must be >= 1
  • GET /events (200) — mock getEventHighPriority — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: priority above maximum (5)"
    • response[0].priority: must be <= 5
  • GET /events (200) — mock getEventDupAttendees — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: duplicate attendees violates uniqueItems"
    • response[0].attendees: must NOT have duplicate items (items ## 1 and 0 are identical)
  • GET /events (200) — mock getEventNoAttendees — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: empty attendees array violates minItems (1)"
    • response[0].attendees: must NOT have fewer than 1 items
  • GET /events (200) — mock getEventBadAttendee — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: invalid email format in attendees"
    • response[0].attendees[0]: must match format "email"
  • GET /events/{eventId} (200) — mock getEventBadNullable — in "Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode) > should fail: wrong type for nullable description (number instead of string|null)"
    • response.description: expected string,null, got number

View full report →

@kevinccbsg
kevinccbsg merged commit 5d4f8c7 into main Sep 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant