feat(action): add manifest-based workflow modes#786
Conversation
80ec07d to
56b78f1
Compare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…three modes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces hashFile() which computes MD5 hex digest of a file using Node.js crypto. Includes test with a fixture PNG image. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Typed functions for storing and retrieving manifest and changeset JSON from S3, with graceful null returns for missing keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pure function that diffs two manifests, producing a changeset with new hashes for added/changed entries and null for deletions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Orchestrates the manifest-generate workflow: runs visual tests, hashes all screenshots, compares against the HEAD manifest to determine what changed, and uploads only changed images plus the new manifest to S3. Supports resize and package-paths options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace top-level imports with injectable deps parameters so tests can pass mocks directly without Bun's global mock.module. This prevents cross-file mock poisoning that was breaking run.test.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes bespoke per-module DI interfaces in favor of the shared Dependencies type from dependencies.ts. manifest-generate now uses deps.s3, deps.core, deps.fs, and deps.hashFile. manifest-s3 takes S3Operations directly. Tests use env vars for action inputs, matching the run.test.ts pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fetches PR, HEAD, and ancestor manifests, then classifies each differing screenshot path as prOwns (PR introduced the change), mainOwns (main changed, PR is clean), or conflict (both sides modified). Returns early with 'match' when all hashes are identical. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The manifest key is now the containing directory relative to the
screenshots root (e.g., "components/Button") rather than the full
file path (e.g., "components/Button/new.png"). This aligns with the
updated plan and makes S3 path construction straightforward—append
/{base,diff,new}.png to the key.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduces two modules: - diff-png: wraps pixelmatch/pngjs to produce a diff PNG from two buffers, handling size mismatches by expanding to max dimensions - manifest-diff: orchestrates downloading base + new from S3, generating the diff, and uploading base.png + diff.png to the comparison directory for the Comparadise web app Only processes prOwns entries with type 'changed' — added and deleted entries are skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wires together classification, diff generation, GitHub status, comment posting, and changeset writing per MANIFEST_PLAN.md step 3. Outcomes: - match: success status, nothing else - only mainOwns: success status (main changed, PR clean) - conflicts: failure status + conflict comment, no changeset - prOwns: diffs + pending status + comment + changeset (with _headSha) Changeset records pr hash for changed/added entries and null for deleted entries, omitting mainOwns paths entirely.
Mirror the manifest-generate convention where the file/function named after the mode is the top-level mode entry point. - manifest-compare.ts (classifier) -> manifest-compare-classify.ts (manifestCompare -> classifyManifests; ManifestCompareDeps -> ClassifyDeps; CompareParams -> ClassifyParams) - run-manifest-compare.ts (orchestrator) -> manifest-compare.ts (runManifestCompare -> manifestCompare; RunManifestCompareDeps -> ManifestCompareDeps; RunManifestCompareParams -> ManifestCompareParams)
Two pure manifest transformations used by manifest-merge: - overlayChangeset(parent, changeset): apply a changeset on top of a parent manifest. Non-null entries set/update hashes; null entries remove keys. The _headSha metadata field is ignored. - detectStaleConflicts(head, parent, changeset): safeguard for the case where a changeset's _headSha differs from the merge parent. Returns paths whose hash differs between the two manifests, which would be clobbered by overlay.
applyChangesetToBaseImages walks a changeset and applies it to S3:
- non-null entries: copy new-images/{prSha}/{path}/new.png
to base-images/{path}/base.png
- null entries: batch-delete base-images/{path}/base.png
Implements step 10 of the manifest-merge plan.
flagOverlappingOpenPrs lists open PRs and, for each whose changeset shares at least one screenshot path with the merging PR's changeset, sets a failure commit status on that PR's head SHA with a 'rebase required' message. Implements step 4 of the manifest-merge plan.
Wires together changeset fetch, conflict prevention, stale-conflict safeguard, manifest overlay, manifest write, and base-image apply per MANIFEST_PLAN.md. Outcomes: - no changeset: copy parent manifest as merge commit manifest, done - changeset with _headSha == parent: flag overlapping open PRs, overlay, write, apply base images - changeset with _headSha != parent: same plus a stale-conflict check using the head manifest as a safeguard against interleaved merges Stale conflicts fail the job (setFailed + thrown error) before any write or base-image apply happens.
Covers manifest-generate, manifest-compare, and manifest-merge modes with example workflow YAMLs for single-package and matrix setups. Documents the required concurrency group on manifest-merge workflows to prevent concurrent merge races on base images.
SVG sequence diagram showing all three manifest modes (generate, compare, merge), their triggers, and data flows to S3 and GitHub API. Referenced from the manifest-workflows docs page.
Shows decision points for all three modes (generate, compare, merge) in a three-column layout with exit badges for success/failure paths.
- Remove original-new-images/ write from manifest-generate (it was incorrectly uploading full-size originals alongside resized images) - Treat deleted screenshots as non-reviewable: write changeset but set success status and skip pending/comment/diffs for deletion-only PRs - Paginate all open PRs in flagOverlappingOpenPrs (was capped at 30) - Improve ancestor-manifest-not-found error to mention running manifest-generate on the base branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matrix manifest-generate jobs now write per-package manifests to
manifests/{sha}/{package-path}.json with package-prefixed keys and
image paths, so parallel jobs no longer overwrite a shared manifest.
manifest-compare squashes those per-package manifests into the single
manifests/{sha}.json before the 3-way comparison; single-package PRs
are unaffected. manifest-merge is unchanged.
Implements the Monorepo support for manifest workflows epic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1874a1c to
c9167db
Compare
…mension note Adds acceptance criterion 4.7 requiring each manifest mode to derive its SHAs/refs/PR identifiers from the triggering event rather than dedicated head-sha/base-ref/pr-sha/pr-number/merge-commit-sha inputs, and expands the 2.2 note to state that diff generation must tolerate a base/new dimension mismatch (padding before pixelmatch). Syncs MANIFEST_AC.md to the canonical manifest-plan content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| head-sha: | ||
| description: 'Optional main HEAD SHA used by manifest-generate to only upload changed screenshots.' | ||
| required: false | ||
| base-ref: | ||
| description: 'Base branch name for manifest-compare (for example, "main").' | ||
| required: false | ||
| pr-sha: | ||
| description: 'PR head SHA for manifest-merge. Falls back to github.event.pull_request.head.sha.' | ||
| required: false | ||
| pr-number: | ||
| description: 'PR number for manifest-merge. Falls back to github.event.pull_request.number.' | ||
| required: false | ||
| merge-commit-sha: | ||
| description: 'Merge commit SHA for manifest-merge. Falls back to github.event.pull_request.merge_commit_sha.' | ||
| required: false |
There was a problem hiding this comment.
We don't need to add these inputs as optional overrides.
Each mode runs on exactly one trigger, so an override input only duplicates a value already in the event payload. The per-commit merge core must remain parameter-driven (decoupled from the event source) so a future trigger — e.g. a push/merge-queue path that resolves the same values from the pushed commit range — can reuse it without these inputs.
| for (const part of parts) { | ||
| if (!part.Key) continue; | ||
| const response = await s3.getObject({ Bucket: bucket, Key: part.Key }); | ||
| const body = await response.Body!.transformToString(); |
There was a problem hiding this comment.
We should not use non-null assertions (!). I will add a new lint rule to ban this in the project
| } | ||
| }; | ||
|
|
||
| async function runManifestCompareWorkflow(deps: Dependencies): Promise<void> { |
There was a problem hiding this comment.
All this new code should be in a separate file or files, to keep the entrypoint file a bit cleaner. (As a follow-up I should extract out the existing stuff in here into its own file)
| if (!Number.isFinite(prNumber)) { | ||
| deps.core.setFailed(`Invalid pr-number: ${prNumberInput}`); | ||
| return; | ||
| } |
There was a problem hiding this comment.
lol I hope PR numbers from github are finite. Let's remove this
| const body = await response.Body!.transformToString(); | ||
| return JSON.parse(body) as Changeset; | ||
| } catch (error: unknown) { | ||
| if (error instanceof Error && error.name === 'NoSuchKey') { |
There was a problem hiding this comment.
Let's reuse the isNoSuchKey() method here and any other places we duplicate this code
| for (const part of parts) { | ||
| if (!part.Key) continue; | ||
| const response = await s3.getObject({ Bucket: bucket, Key: part.Key }); | ||
| const body = await response.Body!.transformToString(); |
| import { readFile } from 'fs/promises'; | ||
|
|
||
| export async function readImageFile(filePath: string): Promise<Buffer> { | ||
| return readFile(filePath); | ||
| } |
There was a problem hiding this comment.
Delete this - it's unused
Summary
workflowinput values to the GitHub Action:manifest-generate,manifest-compare, andmanifest-mergemanifest-generateruns visual tests, hashes each screenshot with MD5, and writes a manifest to S3 atmanifests/{sha}.json; whenhead-shais provided, only images whose hash changed since that SHA are uploaded (differential uploads)manifest-comparefetches the PR and HEAD manifests, finds the merge-base ancestor manifest, and classifies each differing path as PR Owns / Main Owns / Conflict via 3-way hash comparison; PR Owns paths get diff images generated and a changeset written tochangesets/{sha}.json; sets theVisual Regressioncommit status and posts a PR commentmanifest-mergereads the PR's changeset at merge time, overlays it onto the parent manifest, writes the new HEAD manifest to S3, and updatesbase-images/; detects stale changesets and fails any open PRs whose changesets overlap with a conflicting stale baseChanges
action/src/hash.ts— MD5 file hashingaction/src/manifest-s3.ts— typed put/get for manifests and changesetsaction/src/manifest-generate.ts—manifestGenerate()top-level modeaction/src/manifest-compare-classify.ts—classifyManifests()3-way classificationaction/src/manifest-diff.ts—generateDiffs()produces base + diff PNGs in S3action/src/diff-png.ts—diffPng()pixelmatch wrapperaction/src/manifest-compare.ts—manifestCompare()orchestratoraction/src/manifest-merge-overlay.ts—overlayChangeset()anddetectStaleConflicts()(pure transforms)action/src/manifest-merge-base-images.ts—applyChangesetToBaseImages()S3 copy/deleteaction/src/manifest-merge-flag-prs.ts—flagOverlappingOpenPrs()sets failure status on conflicting open PRsaction/src/manifest-merge.ts—manifestMerge()orchestratoraction/src/run.ts— dispatches new workflow values; wires deps for each modeaction.yml— documents new inputs (head-sha,base-ref,pr-sha,pr-number,merge-commit-sha)docs/docs/setup/manifest-workflows.md— setup guide with YAML examples for all three modesdocs/static/img/manifest-workflow.svg— flowchart diagramTest plan
bunx nx test action— all tests passmanifest-generate: manifest written tomanifests/{sha}.json; differential upload skips unchanged images whenhead-shais setmanifest-compare: match path setsVisual Regression→ success; conflict paths → setFailed with comment; PR Owns paths → pending + changeset writtenmanifest-merge: changeset overlaid onto parent manifest; stale + conflicting changeset → setFailed;base-images/updated correctly; overlapping open PRs flagged🤖 Generated with Claude Code