The mechanics of automated publishing lifecycles
Turning merged pull requests into published technical articles requires more than generating text. For solo developers and small teams, developer marketing usually stalls between drafting and distribution. Even when drafts are produced automatically from repository changes, managing publication dates across platforms like Dev.to and Medium introduces non-trivial state management challenges.
A resilient content calendar cannot operate as a basic cron script. Content transitions through distinct phases: drafting, team review, arming, platform dispatch, and post-publication tracking. If the underlying state machine permits inconsistent transitions—such as leaving a demoted draft armed for dispatch or allowing an archived item to overwrite live content—automated publication pipelines fail silently or release unintended drafts.
Herald addresses this distribution bottleneck by pairing an asynchronous worker architecture with a strictly guarded state engine designed to schedule and deliver technical posts reliably.
How Herald's content calendar and state engine work
Herald is built on FastAPI, PostgreSQL, SQLAlchemy, Celery, and Redis, with a frontend delivered through React, Vite, and Tailwind CSS. While AI content generation watches git commits to draft copy when features ship, the release process is controlled by Herald's scheduling backend.
Content scheduling relies on explicit state transitions managed inside PostgreSQL using SQLAlchemy. Posts move sequentially from a generated draft to a scheduled slot on the content calendar, waiting for Celery workers to execute the final dispatch:
# backend/models/post.py
from enum import Enum
from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class PostStatus(str, Enum):
DRAFT = "draft"
REVIEW = "review"
SCHEDULED = "scheduled"
ARMED = "armed"
PUBLISHED = "published"
ARCHIVED = "archived"
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
content = Column(String, nullable=False)
scheduled_for = Column(DateTime, nullable=True)
status = Column(SQLEnum(PostStatus), default=PostStatus.DRAFT, nullable=False)
Recent fixes to Herald's state engine resolve edge cases that traditionally destabilize marketing automation pipelines. For example, when an item was demoted from a scheduled state, Celery task pointers could remain armed, risking accidental deployment. Herald enforces strict guards across CRUD routers and asynchronous workers so that changes in state explicitly synchronize with the worker queue:
# backend/services/scheduler.py
from datetime import datetime, timezone
from sqlalchemy.orm import Session
def disarm_post(db: Session, post: Post) -> None:
if post.status == PostStatus.ARMED:
post.status = PostStatus.DRAFT
post.scheduled_for = None
db.commit()
When a post reaches its target release window, Celery workers poll Redis for armed jobs, resolve credentials, and execute external calls against publishing endpoints such as the Devto API.
Managing the schedule safely
To manage scheduling without accidental dispatches or broken preview links, Herald isolates the preview, approval, and scheduling endpoints inside FastAPI.
When reviewing generated technical posts before queuing them:
- Developers review the generated markdown and platform-specific metadata inside the React interface.
- Preview links can be generated and subsequently revoked cleanly without leaking stale responses.
- The developer picks a distribution slot, moving the post from
reviewtoscheduled. - When armed, delivery routines wrap outgoing API calls inside strict exception guards to prevent worker crashes when third-party endpoints reject machine credentials or throttle requests.
# backend/tasks/delivery.py
import logging
from celery import shared_task
from backend.services.devto import publish_to_devto
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def deliver_post_task(self, post_id: int):
try:
post = fetch_post_by_id(post_id)
if post.status != PostStatus.ARMED:
return False
response = publish_to_devto(post)
post.status = PostStatus.PUBLISHED
commit_post(post)
return True
except Exception as exc:
logger.error("Dispatch failed for post %s: %s", post_id, exc)
raise self.retry(exc=exc, countdown=60)
By formalizing state rules—ensuring archived posts cannot be overwritten by restore operations, guarding webhook execution against unhandled errors, and sanitizing metric polls so tokens never reach backend logs—Herald ensures that developers can leave marketing automation running in the background without worrying about errant or duplicate releases.
Top comments (0)