Publishing a plugin or theme

This guide picks up where Developer onboarding leaves off. Your repo is connected, the GitHub App is installed, and you've configured a release branch. What follows: what triggers a release, how versioning works, what the generated update manifest looks like, and where to look when a push doesn't produce one.

What triggers a release

A release is triggered by a push to the release branch you configured under Configure Product — nothing else. Concretely:

  • Pushes to the release branch (typically main) → release.
  • Merging a pull request into the release branch → release. (GitHub fires a push event for the merge commit.)
  • Direct commits to the release branch → release.
  • Pushes to any other branch → ignored.
  • Pushing a tag → ignored. VelvetPress doesn't currently key off tags.

Under the hood: GitHub sends a push webhook to VelvetPress; the backend validates the signature, then looks up products whose repo_url and branch match the push. Each matching product gets a build job enqueued. You can see the matching logic in backend/src/update-handler/app.py (handle_github_webhook).

If your repo holds the plugin or theme in a subdirectory rather than at the root, the Root directory field on the product picks the subtree to package. Pushes that only touch files outside that subtree still trigger a build — VelvetPress doesn't filter by path. The resulting zip will simply have the same contents as the previous one and customers won't see an update unless the version header changed (next section).

To change which branch publishes releases, edit the product from the dashboard. The webhook is registered against the repo, not the branch, so no GitHub-side reconfiguration is needed.

Versioning conventions

VelvetPress reads the version from the plugin or theme header, not from git tags or commit messages.

Plugins put the version in the main plugin PHP file's header, the same place WordPress reads it from. See plugins/velvetpress/velvetpress.php for the canonical layout:

/**
 * Plugin Name: VelvetPress
 * Description: Automatic theme updates via VelvetPress.
 * Version:     1.0.7
 * Author:      VelvetPress
 * Requires PHP: 7.4
 * Requires at least: 6.0
 */

Themes put the version in style.css, again matching WordPress's own convention. See the VP Starter Theme style.css:

/*
Theme Name: VelvetPress Starter
Description: A minimal block theme starter for VelvetPress deployment.
Version: 1.6.1
Requires at least: 6.0
Requires PHP: 7.4
*/

What happens if you forget to bump the version. VelvetPress detects that the header matches what's already published and skips the build cleanly — the existing release is left as-is and the pipeline row shows ⊝ Skipped (no version bump). See Version skip behavior below for the details and why this is intentional.

Recommended workflow for every release:

  1. Bump the version in the header — style.css for themes, the main plugin file for plugins. Follow SemVer: patch for fixes, minor for additions, major for breaking changes.
  2. Add an entry to CHANGELOG.md. The starter theme follows Keep a Changelog — see the VP Starter Theme CHANGELOG.md for the format. The dashboard doesn't currently render changelog content, but it'll be the first place you look when you have to explain to a customer what changed.
  3. Commit and push to the release branch.

The version header is the source of truth. If you bump style.css from 1.6.1 to 1.6.2, the next push publishes 1.6.2 and that's the version customers see.

Version skip behavior

The Version: header in style.css (themes) or the main plugin file (plugins) is authoritative. VelvetPress decides what version to publish by reading that header at the pushed commit — it doesn't look at git tags, commit messages, or branch names. Whatever string is on that line is the version label customers will receive.

That has a useful side effect: a push that doesn't change the header is a no-op. The webhook still arrives and the dashboard records the push, but VelvetPress compares the header against the currently-published version and skips the build cleanly. The pipeline row shows ⊝ Skipped (no version bump) for that push. The existing release is left exactly as it was — customers who download "this version" tomorrow get the same bytes as customers who downloaded it yesterday. That guarantee is the reason for the skip: re-running a build under the same version label can produce a different zip (different timestamps, possibly different dependency lockfiles), and two customers would otherwise end up with different bytes for what they think is the same release.

To publish a new release, bump the header and push again. Edit the Version: line — for example, 1.2.31.2.4 — commit, and push. The next webhook will see the new value, run the full build, and publish the new zip. Any string works as a version label: VelvetPress compares versions as case-insensitive trimmed strings, not by SemVer semantics, so 1.2.4, v1.2.4, and 2024-05-27 are all valid (as long as you stay consistent enough that WordPress's update checker treats the new label as "newer" than the installed one).

The update manifest

After a build completes, VelvetPress writes a small JSON manifest to S3 at <slug>/theme.json — the same path whether the product is a theme or a plugin. Customer sites poll the public update endpoint, which reads this file and adds a short-lived pre-signed download URL.

The shape is set by handle_release in backend/src/update-handler/app.py. Stored in S3 it looks like this:

{
  "name": "VP Starter Theme",
  "version": "1.6.1",
  "requires": "6.0",
  "requires_php": "7.4",
  "tested": "6.7",
  "details_url": "https://velvetpress.dev/themes/vp-starter-theme"
}

That's the on-disk record — the shape above is what gets written to s3://<bucket>/<slug>/theme.json. When a customer's WordPress site polls GET /update/<slug>, the response is the same object plus a download_url field — a pre-signed S3 URL good for five minutes, pointing at the matching <slug>/<slug>-<version>.zip.

Field by field:

  • name — Human-readable name shown in wp-admin. Pulled from the header (Plugin Name: or Theme Name:) at build time.
  • version — The version from the header. This is what WordPress compares against to decide whether an update is available.
  • requires — Minimum WordPress version. Defaults to 6.0 if not specified in the header.
  • requires_php — Minimum PHP version. Defaults to 7.4.
  • tested — Latest WordPress version this release has been tested against. Defaults to 6.7. Bumping it is a courtesy — WordPress shows a warning on the update screen if tested is older than the running version.
  • details_url — Where the "View details" link in wp-admin sends users. Defaults to https://velvetpress.dev/plugins/<slug> for plugins or https://velvetpress.dev/themes/<slug> for themes (branches on product type) if the build doesn't supply one.
  • download_url — Added on the fly when a customer polls. Pre-signed, five-minute expiry. Not stored in S3.

Field extraction is the build worker's job: it parses the plugin or theme header at the pushed commit and posts the values to handle_release (backend/src/update-handler/app.py). Any field the worker omits gets the default shown above. Bumping Tested up to: in the header is the cleanest way to keep tested from staying stuck at 6.7.

VelvetPress also maintains a release history at <slug>/releases.json — one entry per release, used by the dashboard's release list and by the rollback feature. That file is internal; customer sites never read it.

What happens on the backend

The end-to-end flow, from git push to a customer site seeing an update:

  1. You push. Commit to the release branch lands on GitHub.
  2. GitHub fires the webhook. The VelvetPress GitHub App sends a push event to the backend's /webhooks/github endpoint.
  3. The Lambda matches products. Signature validated, payload parsed, repo_url + branch looked up against the products table. Each match gets a build job enqueued to SQS, and the product's pipeline status flips to Queued in the dashboard.
  4. The build worker packages the release. It clones the repo at the pushed commit, takes the contents of the Root directory, reads the version from the header, and zips it. The zip is uploaded to S3 at <slug>/<slug>-<version>.zip.
  5. The manifest is published. The worker calls POST /themes/<slug>/release with the metadata, which writes <slug>/theme.json and appends to <slug>/releases.json. Pipeline status flips to Built.
  6. Customer sites notice. Each WordPress site running the VelvetPress client plugin polls GET /update/<slug>?api_key=…&domain=… on its own schedule (roughly every twelve hours, plus on demand from the Check Again button). The new manifest comes back, WordPress compares versions, and if the installed version is older, the Update Now button appears in wp-admin.
  7. The user clicks Update Now. WordPress fetches the pre-signed download_url, downloads the zip, and runs its normal upgrade routine.

Step 6 is the only step that's eventually consistent — customer sites poll on their own clock, not in response to a webhook. A site that just polled might wait a few hours before it checks again. The client plugin's Check Again button forces an immediate poll.

For the wire-level details of the customer-facing endpoint (request shape, response fields, auth headers), see Update manifest reference.

Troubleshooting: "I pushed but no release appeared"

When a push doesn't produce a release, there are three places to look, in order:

1. The product card in the dashboard. Open the product and check the Pipeline row. It shows one of four states next to the short commit SHA and a timestamp:

  • ⏳ Queued — the webhook arrived and a build job was enqueued. If it's been stuck here for more than a minute, the build worker may be down. Check back in a few minutes before assuming something's wrong.
  • ⚙ Building — a build is in progress. Normal builds finish in under a minute.
  • ✓ Built — the release is published. If you see this with the expected commit SHA and the new version isn't showing up in wp-admin, the issue is on the WordPress side: the customer site hasn't polled yet, or its API key/domain isn't accepted by the backend. Click Check Again in the WordPress admin.
  • ✗ Error — the build failed. Hover the red text next to the status to see the error message. Common causes: the Root directory path is wrong, the repo couldn't be cloned (revoked GitHub App access), or the header doesn't parse.

If the Pipeline row is blank or its timestamp is older than your push, the webhook itself probably didn't arrive. Move to step 2.

2. GitHub's webhook delivery log. VelvetPress registers a normal repo-level webhook on first product setup (/api/github/setup), not an App-level webhook — so the deliveries live in your repo's Settings → Webhooks → Recent Deliveries, not under GitHub App settings. Click the VelvetPress hook and open the Recent Deliveries tab. Each delivery shows the response code VelvetPress returned. A green check means the webhook fired and was accepted; a red X means the request didn't reach the backend or was rejected (usually a signature mismatch — most often caused by reinstalling the App without updating the registered webhook secret).

You can re-deliver any failed webhook from that screen to retry without making a new commit.

3. The branch you actually pushed to. If the Pipeline row is from an older commit on a different branch, you probably pushed to a non-release branch. Confirm the product's configured Branch matches the branch you pushed to.

Deeper troubleshooting — checking S3 directly, inspecting the SQS queue, re-running a failed build — belongs in the operator-level docs (out of scope for this page).

What's next

You've published a release. From here:

  • Install the client plugin on customer sites so they can poll for updates. See Installing on a customer WordPress site.
  • Look up the update endpoint shape if you're integrating from a non-VelvetPress client. See Update manifest reference.
  • Roll back a bad release from the product card in the dashboard. The rollback writes a new release entry pointing at the older zip — your next push then supersedes it.