# Releasing seekmodo-wordpress-connector

Routine releases ship via an **operator-local** build that opens an
auto-merging PR against [`numinix/seekmodo`](https://github.com/numinix/seekmodo).
That PR lands the signed zip + manifest entry under
`services/marketing-site/public/plugins/`, and the seek-api01 push
webhook redeploys `seekmodo-marketing.service` — so `https://seekmodo.com/plugins/seekmodo-wordpress-v<X.Y.Z>.zip`
is downloadable a few minutes later.

This mirrors the [`seekmodo-zen-cart-connector`](https://github.com/numinix/seekmodo-zen-cart-connector)
release pipeline. **GitHub Actions is not used** for routine releases
(see [`numinix/seekmodo/.cursor/rules/deploy.mdc`](https://github.com/numinix/seekmodo/blob/main/.cursor/rules/deploy.mdc)
and `AGENTS.md` §1). The `.github/workflows/release.yml` workflow in
this repo is `workflow_dispatch`-only as an emergency fallback for the
case where the operator workstation is unavailable.

---

## TL;DR (the happy path)

```bash
# 1. Make sure CHANGELOG.md, readme.txt "Stable tag", and the plugin
#    header "Version:" in seekmodo-wordpress.php all agree on X.Y.Z.

# 2. Tag + push.
git tag -a vX.Y.Z -m "vX.Y.Z - short description"
git push origin vX.Y.Z

# 3. Build + ship.
python tools/build_release.py --auto-pr
```

Step 3 produces `dist/seekmodo-wordpress-vX.Y.Z.zip{,.sha256,.sig}`,
clones `numinix/seekmodo`, drops the artefacts into
`services/marketing-site/public/plugins/`, updates `manifest.json`,
opens a PR with the title `plugins: seekmodo-wordpress vX.Y.Z`. Merge
it (or let auto-merge land it) and the marketing-site webhook handles
the rest.

---

## Prerequisites (one-time setup on your workstation)

| Requirement | Where it lives | Notes |
|---|---|---|
| Python 3.10+ with PyNaCl | `pip install pynacl` | The signing step needs `nacl.signing`. |
| `gh` CLI authenticated for `numinix/*` | `gh auth status` | Used by `--auto-pr` to clone, push, and open the PR. |
| `SEEKMODO_PUBLISH_TOKEN` env var | `~/.zshrc`, `~/.config/fish/`, or PowerShell `$PROFILE` | PAT or fine-grained token with `repo` scope on `numinix/seekmodo`. Required by `--auto-pr`. **Without it, the script gracefully degrades and only produces the local `dist/` artefacts** — you'll need to commit them into `numinix/seekmodo` manually. |
| ed25519 signing seed | `$RELEASE_SIGNING_KEY_PATH` env var, or one of `/etc/numinix/release-signing.key`, `/etc/numinix/marketing-jwt.ed25519`, `~/.numinix/release-signing.key` | 32 raw bytes **or** 64-char hex. Without a key the script WARNS and skips the `.sig` (still produces zip + sha256). |

### Generating a one-shot dev-ephemeral key (matches existing manifest posture)

The current zen_cart and wordpress manifest entries both advertise
`"sig_kid": "dev-ephemeral"` — meaning no rotation-capable stable
signing identity has been established yet. Until that's set up
(tracked separately), the conventional pattern is to generate a
burn-after-use key per release:

```powershell
# Windows
python -c "import nacl.signing; sk=nacl.signing.SigningKey.generate(); open(r'$env:USERPROFILE\.numinix\release-signing.key','w').write(bytes(sk).hex())"
```

```bash
# Unix
mkdir -p ~/.numinix && python3 -c "import nacl.signing; sk=nacl.signing.SigningKey.generate(); open('$HOME/.numinix/release-signing.key','w').write(bytes(sk).hex())"
```

Writing the seed as 64-char hex (not raw bytes) sidesteps a subtle
`.strip()` issue when the random seed happens to end in a whitespace
byte (`0x09`/`0x0a`/`0x0d`/`0x20`).

---

## Step-by-step

### 1. Pre-flight

- Confirm `main` is in the state you want to ship.
- All three version markers agree:
  - `seekmodo-wordpress.php` plugin header: `Version: X.Y.Z`
  - `seekmodo-wordpress.php` constant: `define('SEEKMODO_WP_VERSION', 'X.Y.Z')`
  - `readme.txt`: `Stable tag: X.Y.Z`
  - `CHANGELOG.md` has a `## [X.Y.Z] - YYYY-MM-DD` heading with the
    actual changes.
- `tools/build_release.py --bump patch|minor|major` can update the
  first three for you and re-run.

### 2. Tag + push

```bash
git tag -a vX.Y.Z -m "vX.Y.Z - short description for git tag/release notes"
git push origin vX.Y.Z
```

The tag carries the release notes that `gh release create` picks up
later (see step 5).

### 3. Build (operator-local)

```bash
python tools/build_release.py
```

Produces in `dist/`:

- `seekmodo-wordpress-vX.Y.Z.zip`
- `seekmodo-wordpress-vX.Y.Z.zip.sha256` (lowercase hex + filename, GNU `sha256sum` format)
- `seekmodo-wordpress-vX.Y.Z.zip.sig` (raw 64-byte detached ed25519 signature over the zip bytes)

### 4. Ship to seekmodo.com (auto-PR path)

```bash
python tools/build_release.py --auto-pr
```

Idempotent — re-running with the same version just no-ops the build
(or rebuilds the zip with the same hash, modulo zip metadata) and
re-uses an existing PR if one already exists for the branch.

What it does:

1. Shallow-clones `numinix/seekmodo` to a temp dir.
2. Copies the three artefacts into `services/marketing-site/public/plugins/`.
3. Reads + updates `manifest.json`:
   ```json
   {
     "platforms": {
       "wordpress": {
         "latest": "X.Y.Z",
         "url": "https://seekmodo.com/plugins/seekmodo-wordpress-vX.Y.Z.zip",
         "sha256": "<lowercase hex>",
         "released_at": "<ISO8601 +00:00>",
         "release_notes_url": "https://seekmodo.com/plugins/wordpress#changelog",
         "min_compatible_gateway": "0.1.0",
         "sig": "<unpadded base64 of 64-byte detached sig>",
         "signed_at": "<same as released_at>",
         "sig_kid": "dev-ephemeral",
         "signed_with": "dev-ephemeral"
       }
     }
   }
   ```
   (Schema matches the live `zen_cart` entry — see
   `numinix/seekmodo-zen-cart-connector/zc_plugins/Seekmodo/v1.0.17/catalog/includes/library/Numinix/Seekmodo/UpdateClient.php`
   for the consumer.)
4. Pushes `plugins/seekmodo-wordpress-vX.Y.Z` branch and opens a PR
   titled `plugins: seekmodo-wordpress vX.Y.Z`.

Merge that PR (squash). The seek-api01 webhook fires within seconds
and `seekmodo-marketing.service` rebuilds + restarts in ~3–4 minutes.

### 5. Cut the GitHub Release (artefact archive)

```bash
gh release create vX.Y.Z \
  --repo numinix/seekmodo-wordpress-connector \
  --title "vX.Y.Z - one-line summary" \
  --notes-file path/to/notes.md \
  dist/seekmodo-wordpress-vX.Y.Z.zip \
  dist/seekmodo-wordpress-vX.Y.Z.zip.sha256 \
  dist/seekmodo-wordpress-vX.Y.Z.zip.sig
```

This is the authoritative archive of release artefacts (seekmodo.com's
copy is the *current* distributable; the GitHub Release is the *historical*
record). Notes should mirror the relevant CHANGELOG entry.

### 6. Verify

```bash
# Live manifest serves the new entry
curl -fs https://seekmodo.com/plugins/manifest.json | jq .platforms.wordpress

# Zip is downloadable and the SHA matches
curl -fsSO https://seekmodo.com/plugins/seekmodo-wordpress-vX.Y.Z.zip
sha256sum seekmodo-wordpress-vX.Y.Z.zip
# Should match the .sha256 sidecar:
curl -fs https://seekmodo.com/plugins/seekmodo-wordpress-vX.Y.Z.zip.sha256
```

---

## Fallback: when `--auto-pr` can't open the PR

If `SEEKMODO_PUBLISH_TOKEN` is not configured (or `gh` can't talk to
`numinix/seekmodo`), the script still produces `dist/`. To finish the
release manually:

```bash
# Use a worktree to avoid colliding with other agents.
cd ~/Repositories/Clients/seekmodo.com/seekmodo
./tools/agent_worktree.ps1 -Tag wp-release-vX-Y-Z   # PowerShell on Windows
# (or just create a feature branch off main in your usual checkout)

cd ../seekmodo-wp-release-vX-Y-Z  # or wherever
cp ../seekmodo-wordpress-connector/dist/seekmodo-wordpress-vX.Y.Z.zip*    \
   services/marketing-site/public/plugins/

# Edit services/marketing-site/public/plugins/manifest.json by hand,
# adding the platforms.wordpress entry per the schema in step 4 above.

git add services/marketing-site/public/plugins/
git commit -m "release: seekmodo-wordpress vX.Y.Z"
git push -u origin <branch>
gh pr create --repo numinix/seekmodo --base main --head <branch> \
  --title "release: seekmodo-wordpress vX.Y.Z" --body "..."
gh pr merge <#> --repo numinix/seekmodo --squash --delete-branch
```

This is the same precedent used for `seekmodo-zen-cart` v1.0.13
(commit `c259cb5` on `numinix/seekmodo`) and the first publish of
`seekmodo-wordpress` v0.1.3 (PR #55, commit `c4c0110`).

---

## Emergency fallback (GHA `workflow_dispatch`)

If your workstation is unavailable, `.github/workflows/release.yml` in
this repo runs the same `tools/build_release.py --auto-pr` pipeline
under GHA. Trigger it manually from the Actions tab — it's
`workflow_dispatch`-only and will not fire on tag push (deliberately,
to avoid burning GHA minutes on routine releases). The workflow needs
the `SEEKMODO_PUBLISH_TOKEN` and `RELEASE_SIGNING_KEY` repo secrets.

---

## Anti-patterns (don't do these)

- **Don't add an `on: push` or `on: release` trigger to `release.yml`.**
  The deploy policy explicitly forbids GHA tag-triggered deploys; see
  the comment header in that file and `numinix/seekmodo/.cursor/rules/deploy.mdc`.
- **Don't merge the zip into `numinix/seekmodo` `main` without the
  `manifest.json` update.** The webhook redeploys marketing-site but
  the connector auto-update consumer relies on `manifest.json` to
  discover the new release; an orphan zip is invisible.
- **Don't bump only one of the three version markers.** The plugin
  header version, the `SEEKMODO_WP_VERSION` constant, and the
  `Stable tag` line all need to agree, or self-reported version
  metadata in beacons + analytics goes inconsistent.
- **Don't ship without a matching `## [X.Y.Z]` entry in CHANGELOG.md.**
  The GitHub Release notes and the manifest's `release_notes_url`
  both expect that anchor.

---

## See also

- `tools/build_release.py` — the script itself; read it first when in
  doubt.
- `numinix/seekmodo/.cursor/rules/deploy.mdc` — canonical deploy
  policy.
- `numinix/seekmodo/AGENTS.md` §1 — "How code ships".
- `numinix/seekmodo-zen-cart-connector/RELEASING.md` (if/when it
  exists) — the sibling pattern.
