feat(content-releases): finalize plugin#7
Open
owarpero wants to merge 17 commits into
Open
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…decision Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Adds libsql and @eslint/eslintrc to apps/dev (required for sqlite + eslint config). - Regenerated importMap to register new ReleaseStatusField, ReleaseItemStatusField, ReleaseActionCell. - Regenerated payload-types after removing errorLog field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ReleaseSidebarField: tighter gap between sidebar buttons (margin=false on Button removes default 24px vertical margin). - ReleaseDrawer: 'Create New Release' wrapped in <div> so it gets auto width instead of stretching across the drawer. - VersionPickerDrawer/ReleaseSidebarField/ReleaseDrawer: drop unused React imports (auto JSX runtime). - releases.ts: scheduledAt validate function rejects past dates, but allows unchanged value (compares against options.previousValue) so re-saving an old draft doesn't trigger. - releases.ts items join: admin.allowCreate = false hides the misleading 'Add new'/'Create new Release Item' buttons; admin.description now clarifies that resources are added from the document sidebar. - constants/types: add 'reverted' to RELEASE_ITEM_STATUSES + ReleaseItemStatus. - ReleaseItemStatusField: pillStyle 'dark' for reverted. - orchestrateRollback: after each successful restore, look up the matching release-item and set its status to 'reverted'. - releaseItemsBeforeChange: extend the 'publishing' status-only-update bypass to also cover 'reverting', so the orchestrator can set item.status during rollback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a release has a scheduledAt date set, its status should reflect 'scheduled' rather than 'draft' so the scheduler picks it up. Likewise, clearing scheduledAt should bring the release back to 'draft'. Behaviour: - create with scheduledAt → status='scheduled' (otherwise 'draft' as before) - update draft → add scheduledAt → status flips to 'scheduled' - update scheduled → clear scheduledAt → status flips back to 'draft' - editing other fields (description, name) without touching scheduledAt leaves status untouched - explicit status changes (e.g. from orchestrator) still go through the existing transition validator Covered by 5 new vitest cases for releasesBeforeChange. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ecovery path
Root cause analysis (systematic-debugging):
1) Frontend (ReleaseActionsField) used a 3-state blacklist
(publishing/published/failed → disabled, default → enabled). This
left 'reverting', 'reverted', and 'cancelled' rendering an active
button even though only 3 states actually allow the
'X → publishing' transition.
2) The publish endpoint mirrored the same incomplete blacklist, so
a click on 'reverted' fell through the guard into the orchestrator,
which called payload.update({status: 'publishing'}) and triggered
releasesBeforeChange to throw 'Invalid status transition'. Endpoint
caught the throw and returned a 500 with a confusing message.
3) Once a release reached 'reverted' (especially via a skip-only
rollback where nothing was actually reverted), there was no path
back to 'draft' — VALID_TRANSITIONS.reverted was [].
Fix:
- Extract getPublishButtonProps to its own module so it can be unit-tested
without pulling @payloadcms/ui (which transitively imports CSS).
- Whitelist via isValidTransition(status, 'publishing') — single source
of truth.
- Endpoint guard rewritten to use the same isValidTransition check —
returns a clean 400 with status name in the error message.
- VALID_TRANSITIONS.reverted = ['draft'] gives reverted releases a
recovery path; statusTransitions tests cover the new edge.
- New tests:
- getPublishButtonProps: 11 cases (3 enabled, 5 disabled, undefined)
- publishRelease handler: rejection set extended with 'reverting' and
'reverted'.
Verified via chrome-devtools MCP: 'Reverted' release shows the button
disabled with tooltip 'Release has been reverted'; POST /publish returns
400 with 'Cannot publish a release with status "reverted"'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eleases
Adds a recovery action next to 'Publish Now' that PATCHes the release
back to 'draft' so it can be edited and republished. The button is
visible only when isValidTransition(status, 'draft') is true — i.e.
'failed' (failed → draft) and 'reverted' (reverted → draft, added
in the previous commit).
This closes the UX trap where, after a rollback (especially the
skip-only case where nothing was actually reverted), the release was
stuck in a terminal state with no path forward from the admin UI.
The handler hits the standard /api/releases/{id} endpoint with
{status: 'draft'}; releasesBeforeChange validates the transition and
the existing auto-flip stays out of the way (it only kicks in for
draft/scheduled).
Verified via chrome-devtools MCP: clicking 'Reset to draft' on a
'failed' release transitioned it to 'draft', cleared the reset
button, and re-enabled 'Publish Now'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reproduced via end-to-end API flow:
1. publish release → page.updatedAt = T_publish
2. rollback → executeRollback restores previousState via
payload.update, which generates a NEW
page.updatedAt (T_rollback). item.baseVersion
was left at T_staging.
3. reset to draft + republish → executePublish detected
page.updatedAt T_rollback != baseVersion T_staging
and marked the item as a conflict
("Conflict: document modified since staging…"),
leaving the release in 'failed' state with no
clean recovery path.
Root cause: rollback was not the user touching the page, so its
own write should not appear as an external modification to the
release. baseVersion is meant to track 'the version we are aware of';
after a rollback we are aware of the new updatedAt because we just
wrote it.
Fix:
• executeRollback now returns newUpdatedAt for each restored entry
(read off the doc returned by payload.update).
• orchestrateRollback updates the matching release-item with both
status='reverted' and baseVersion=newUpdatedAt. The
releaseItemsBeforeChange hook already allows status-bearing
updates while the release is in 'reverting'.
• executeRollback test updated to assert the new shape.
Verified via the same end-to-end script used to surface the bug:
republish after rollback now returns 1 published / 0 failed and the
release lands back in 'published' instead of 'failed'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h-snapshot
Previously a release that landed in 'failed' (conflict between staged
snapshot and the document's current state) had no recovery path from
the admin UI. Reset to draft + republish would just hit the same
conflict again because item.baseVersion / item.snapshot were stale.
This adds:
1. New endpoint POST /api/content-releases/items/:itemId/refresh-snapshot
It re-reads the target document via Local API, drops db-managed
keys, and writes the current state back to the item as the new
snapshot + baseVersion, while resetting item.status to 'pending'.
2. releaseItemsBeforeChange now respects a context flag
({ contentReleasesBypass: true }) so the refresh endpoint can
update an item even when the parent release is 'failed' or
'reverted'. The hook still gates all other write paths.
3. ReleaseActionsField fetches release-items with status='failed' when
the release itself is 'failed' and renders an error Banner above
the action buttons. The banner explains the typical cause and
exposes a per-item 'Refresh snapshot' button. After refreshing,
item.status flips to 'pending' and the banner item disappears;
the user can then 'Reset to draft' → 'Publish Now' and the
release republishes cleanly.
Verified via chrome-devtools MCP on a real conflict scenario:
publish (ok) → manual page edit → republish (failed) →
banner shows 1 failed item → click 'Refresh snapshot' →
item becomes pending → reset to draft → publish now → published.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # apps/dev/package.json # apps/dev/src/app/(payload)/admin/importMap.js # apps/dev/src/payload-types.ts # bun.lock # packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts # packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts # packages/payload-plugin-content-releases/src/__tests__/endpoints/publishRelease.test.ts # packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts # packages/payload-plugin-content-releases/src/__tests__/hooks/releasesBeforeChange.test.ts # packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts # packages/payload-plugin-content-releases/src/__tests__/validation/statusTransitions.test.ts # packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx # packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx # packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx # packages/payload-plugin-content-releases/src/admin/components/VersionPickerDrawer.tsx # packages/payload-plugin-content-releases/src/client.ts # packages/payload-plugin-content-releases/src/collections/releaseItems.ts # packages/payload-plugin-content-releases/src/collections/releases.ts # packages/payload-plugin-content-releases/src/constants.ts # packages/payload-plugin-content-releases/src/endpoints/publishRelease.ts # packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts # packages/payload-plugin-content-releases/src/hooks/releasesBeforeChange.ts # packages/payload-plugin-content-releases/src/plugin.ts # packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts # packages/payload-plugin-content-releases/src/rollback/executeRollback.ts # packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts # packages/payload-plugin-content-releases/src/types.ts # packages/payload-plugin-content-releases/src/validation/statusTransitions.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the action items from the Payload Guild Sync 2026-04-23 for the content-releases plugin.
releases.statusandrelease-items.statuskeep theirselectdata semantics for validation, but the dropdown UI is replaced with a custom Field component (ReleaseStatusField,ReleaseItemStatusField) that renders a Payload-nativePill. Status is no longer editable from admin — driven by hooks/orchestrator only.statuscolumns from the items table (release detail page) — both at the join field'sdefaultColumnsand the collection's owndefaultColumns.action→ "Release action" (label) and added a PillCell(ReleaseActionCell) for that column —successforpublish,warningforunpublish.errorLogfield entirely (per the meeting's "no concurrent releases → no error logging" decision). Cleaned up the writes inorchestratePublish.executePublishcovering deeply nested blocks (form-builder-style), populated relationship objects, snapshot immutability, deep rollback capture, and unpublish isolation. All pass against the current implementation — no production change needed.UI components used
All new components are built on
@payloadcms/uiexports (Pill,useField) — no custom fonts, no inline hex colors, no extraneous styling. The Pill colors come from Payload theme tokens; the font is inherited frombody.Verified via Chrome DevTools MCP
On a live
apps/devinstance:<div class="pill pill--style-light-gray pill--size-medium"><span class="pill__label">Draft</span></div>— no<select>,<input>or react-select in the field container.Target Collection / Target Doc / Release action. NoStatuscolumn.<div class="pill pill--style-success pill--size-medium">Publish</div>.<h4>Resources</h4>.Error Logfield rendered on the form.font-familymatchesbody(inherited, not overridden).Test plan
cd packages/payload-plugin-content-releases && bun run test→ 124 passed (21 files)bun run build(turbo) — plugin package builds clean;dist/client.{js,d.ts}includesReleaseStatusField,ReleaseItemStatusField,ReleaseActionCellNotes
Bumped
apps/devdeps: addedlibsql(sqlite runtime, was missing) and@eslint/eslintrc(required by the existing eslint config). RegeneratedimportMap.jsandpayload-types.ts.