# Making-of: Shipping and Auto-Updating Your Own WordPress Plugins with GitHub Actions

> How a WordPress plugin outside the .org directory gets the one-click update experience: GitHub Actions builds the ZIP and checksum, a static JSON manifest on gh-pages replaces the rate-limited GitHub API, and a lean updater class hooks safely into WordPress' update system.

Source: https://www.jpkc.com/db/en/blog/wordpress-plugin-auto-updates-github-actions/

If you write a WordPress plugin that doesn't live in the official [wordpress.org](https://wordpress.org/plugins/) directory — because it's too niche, stays internal, or simply doesn't belong there — you lose one thing you only miss once it's gone: the **one-click update experience** in the backend. No "Version 1.3.0 available", no blue update button, no automatic install. Instead: download the ZIP, upload via FTP, delete the old folder, hope for the best.

It doesn't have to be that way. WordPress' update system is extensible — you can build a plugin so it pulls its updates from its **own source** and still feels exactly like a directory plugin. This article is the making-of such a setup: a **GitHub Actions pipeline** automatically builds the ZIP, a **checksum** and a **JSON manifest** on every version tag; the manifest sits statically on **GitHub Pages** (gh-pages); and a compact **updater class** inside the plugin reads it and hooks safely into WordPress' update system.

The trick is in the combination: because the manifest is a static file on gh-pages and *not* the GitHub API, thousands of WordPress installs can hit the same source without ever running into a rate limit. I'm deliberately not naming the plugin here — the mechanics are what's interesting, and they transfer to any plugin of your own. Throughout the code examples, the placeholder slug `jpkcom-example` stands in.

## The three building blocks and the data flow

Before the code, the big picture. There are three parts that come together through exactly **one** action: a Git tag.

1. **The release pipeline** (GitHub Actions, `release.yml`). Runs as soon as a tag like `v1.3.0` is pushed. It builds the plugin ZIP, computes its SHA256 checksum, attaches both to a GitHub release, and generates a **JSON manifest** from the `README.md`.
2. **The manifest on gh-pages.** A static `plugin_jpkcom-example.json`, deployed to the `gh-pages` branch and served through GitHub's CDN. It's the "source of truth" for which version is current, where the ZIP lives, and what checksum it must have.
3. **The updater class** inside the plugin (PHP). It queries the manifest, compares the version against the installed one, tells WordPress about an update when needed — and verifies the checksum before installing.

The data flow is a one-way street from the tag all the way into the wp-admin update screen:

```text
git tag v1.3.0 && git push --tags
        │
        ▼
GitHub Actions (release.yml)
        ├── builds   jpkcom-example.zip
        ├── computes SHA256
        ├── uploads  ZIP + .sha256 to the GitHub release
        └── generates plugin_jpkcom-example.json  ──► deploy to gh-pages
                                                            │
                                                            ▼
                                       https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json
                                                            │
                          (static fetch, no API token, no rate limit)
                                                            │
                                                            ▼
                                   updater class in plugin  ──►  wp-admin: "Version 1.3.0 available"
```

Each part has exactly one job and a clear interface: the pipeline *produces* the manifest, the manifest *describes* the release, the class *consumes* the manifest. You can understand and test each part in isolation — and that's exactly what we'll go through, one at a time.

## Part 1 — GitHub Actions: from Git tag to release

The pipeline triggers on version tags only. That's half the battle for a clean release: not every commit fires something off, only the deliberate act of "this is now version X".

```yaml
name: Build WordPress Plugin ZIP, Generate Manifest & Deploy

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write   # needed to write release assets and the gh-pages branch
```

`permissions: contents: write` is deliberately minimal: the built-in `GITHUB_TOKEN` gets exactly the write access it needs for the release and gh-pages — nothing more. No personal access token, no secret to manage and rotate.

### Deriving variables from the tag

The first job step pulls all the key facts from context rather than duplicating them somewhere. The slug is the repo name, the version is the tag without the leading `v`, and the download URL already points at the release asset that's about to be created:

```bash
echo "PLUGIN_SLUG=$(basename $GITHUB_REPOSITORY)" >> $GITHUB_ENV
TAG_NAME=${GITHUB_REF##*/}          # e.g. "v1.3.0"
TAG_NAME=${TAG_NAME#v}              # -> "1.3.0"
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV
echo "ZIP_FILE=$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF##*/}/$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENV
```

The crucial idea: **there is no hardcoded version number anywhere in the workflow.** The tag is the single source. Push `v1.3.0` and you get a `1.3.0` release with a matching ZIP name and matching download URL — without touching a single file.

### Building the ZIP — with the right exclusions

A plugin ZIP should contain only what belongs on the target install. All the development scaffolding — `.git`, the workflow definition, build scripts, doc generators — has to go, or it bloats the package and leaks internals. `rsync` with an exclude list into a cleanly named staging directory is the robust tool for that:

```bash
STAGING_DIR="${{ env.PLUGIN_SLUG }}"
mkdir -p "$STAGING_DIR"
rsync -a \
  --exclude='.git' --exclude='.gitignore' --exclude='.github' \
  --exclude='.DS_Store' --exclude='*.py' \
  --exclude='CLAUDE.md' --exclude='.claude' \
  --exclude='gh-pages-*' --exclude='docs' --exclude='phpdoc*' \
  . "$STAGING_DIR/"
zip -r "${{ env.ZIP_FILE }}" "$STAGING_DIR"
rm -rf "$STAGING_DIR"
```

The important bit is the detour through `$STAGING_DIR`: it makes the ZIP contain **a single top-level folder** named after the plugin slug — exactly the structure WordPress expects when unpacking. A ZIP with its files flat in the root would drop the plugin in the wrong place on install.

### Checksum: the security foundation

Right after the ZIP comes the step that later carries the entire security chain — the SHA256 checksum. It's computed, written to an environment variable (for the manifest) *and* stored as a separate `.sha256` file (for manual verification):

```bash
SHA256_HASH=$(sha256sum "${{ env.ZIP_FILE }}" | awk '{print $1}')
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
echo "$SHA256_HASH  ${{ env.ZIP_FILE }}" > ${{ env.ZIP_FILE }}.sha256
```

This number is the fingerprint of exactly the ZIP the pipeline just built. It travels into the manifest next — and the updater class will refuse to install anything that doesn't carry this fingerprint. More on that later.

### Uploading to the release

Uploading is handled by an established action. `generate_release_notes: true` lets GitHub assemble the release notes automatically from the commits since the last tag:

```yaml
- name: Upload ZIP and checksum to GitHub release
  uses: softprops/action-gh-release@v2
  with:
    tag_name: ${{ github.ref_name }}
    generate_release_notes: true
    files: |
      ${{ env.ZIP_FILE }}
      ${{ env.ZIP_FILE }}.sha256
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

From here the ZIP is publicly retrievable at the `DOWNLOAD_URL` built above — through GitHub's release download infrastructure, **not** through the API. That distinction is the heart of the rate-limit topic further down.

### Generating the manifest

The last substantive step builds the JSON file WordPress will read later. I do this in a Python step (`shell: python`), because assembling nested JSON structures in Bash gets unreadable fast. Simplified, the core looks like this:

```python
import os, json

plugin = {
    "name":            meta.get("PluginName", ""),
    "slug":            os.environ["PLUGIN_SLUG"],
    "version":         meta.get("Version", os.environ["TAG_NAME"]),
    "download_url":    os.environ["DOWNLOAD_URL"],
    "checksum_sha256": os.environ.get("SHA256_HASH", ""),   # <- the fingerprint
    "requires":        meta.get("Requiresatleast", "6.8"),
    "tested":          meta.get("Testedupto", "6.9"),
    "requires_php":    meta.get("RequiresPHP", "8.3"),
    "last_updated":    os.environ["DATE"],
    "sections": {                                            # HTML for the "Details" modal
        "description":  load_section_html("description"),
        "installation": load_section_html("installation"),
        "changelog":    load_section_html("changelog"),
        "faq":          load_section_html("faq"),
    },
}

with open(f"gh-pages-json/plugin_{os.environ['PLUGIN_SLUG']}.json", "w", encoding="utf-8") as f:
    json.dump(plugin, f, indent=2, ensure_ascii=False)
```

The `meta` values and the `sections` come from the `README.md`: an earlier step uses `pandoc` and `awk` to pull out the `## Description`, `## Installation`, `## Changelog` and `## FAQ` sections and render them to HTML — exactly what WordPress shows in a plugin's "View details" modal. That keeps the `README.md` the **single source** for the description: maintain it once, current everywhere (GitHub, manifest, WP backend).

### Deploying to gh-pages

Finally, the manifest (plus the rendered HTML snippets and the banners/icons) lands on the `gh-pages` branch:

```yaml
- name: Deploy to gh-pages
  uses: peaceiris/actions-gh-pages@v4
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    publish_dir: gh-pages-deploy
    publish_branch: gh-pages
    keep_files: false
```

`keep_files: false` makes the gh-pages branch fully rewritten on every release — so no stale files accumulate. Once the branch is published as GitHub Pages, the manifest sits at a stable, static URL:

```text
https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json
```

That completes Part 1: a single `git push --tags` is enough, and a few minutes later there's a release ZIP with a checksum and an updated manifest. No manual step in between.

## Part 2 — The manifest: the contract between pipeline and plugin

The manifest is deliberately just a plain JSON file. It's the **contract**: the pipeline commits to filling it in correctly, the plugin relies on reading it correctly. Excerpt:

```json
{
  "name": "JPKCom Example",
  "slug": "jpkcom-example",
  "version": "1.3.0",
  "download_url": "https://github.com/JPKCom/jpkcom-example/releases/download/v1.3.0/jpkcom-example.zip",
  "checksum_sha256": "9f2c…a1b7",
  "requires": "6.8",
  "tested": "6.9",
  "requires_php": "8.3",
  "last_updated": "2026-07-06",
  "sections": {
    "description": "<p>…</p>",
    "changelog": "<h4>1.3.0</h4><ul><li>…</li></ul>"
  }
}
```

Only a handful of fields carry the actual update logic:

| Field | What WordPress needs it for |
| --- | --- |
| `version` | Compared against the installed version — only if newer is there an update |
| `download_url` | Where the ZIP is pulled from (the release asset) |
| `checksum_sha256` | The fingerprint checked before installation |
| `requires` / `tested` | Compatibility display in the backend (WordPress version) |
| `requires_php` | Blocks the update on too-old PHP versions |
| `sections` | HTML for the "View details" modal (description, changelog, FAQ) |

That this is a *static* document is not a compromise, it's the actual trick. There's no server generating it dynamically, no database, no PHP process on the other end. It's a file on a CDN. That makes it fast, resilient and — the crux — free of rate limits.

## Part 3 — The updater class: hooking cleanly into WordPress' update system

Now the consumer side. WordPress periodically checks whether updates exist for installed plugins, and provides filters you can hook into for that. The class (here `JPKComGitPluginUpdater`) does two things in the constructor: it validates the environment and the manifest URL, and it registers its hooks.

```php
public function __construct( string $plugin_file, string $current_version, string $manifest_url ) {
    global $wp_version;

    // Environment gate: don't even become active below PHP 8.3 / WP 6.8
    if ( version_compare( PHP_VERSION, '8.3', '<' )
         || version_compare( $wp_version, '6.8', '<' ) ) {
        return;
    }

    // Security: sanitize and validate the manifest URL before anything happens
    $manifest_url = esc_url_raw( $manifest_url );
    if ( ! wp_http_validate_url( $manifest_url ) ) {
        return; // invalid URL -> abort initialization
    }

    $this->plugin_file     = $plugin_file;
    $this->plugin_slug     = dirname( plugin_basename( $plugin_file ) );
    $this->current_version = $current_version;
    $this->manifest_url    = $manifest_url;
    $this->cache_key       = 'jpk_git_update_' . md5( $this->plugin_slug );

    add_filter( 'plugins_api',                    [ $this, 'plugin_info' ], 20, 3 );
    add_filter( 'site_transient_update_plugins',  [ $this, 'check_update' ] );
    add_action( 'upgrader_process_complete',      [ $this, 'clear_cache' ], 10, 2 );
    add_filter( 'upgrader_pre_download',          [ $this, 'verify_download_checksum' ], 10, 3 );
}
```

The four hooks map to four clear jobs:

- **`site_transient_update_plugins`** → `check_update()`: tells WordPress an update is available.
- **`plugins_api`** → `plugin_info()`: fills the "View details" modal from the manifest.
- **`upgrader_pre_download`** → `verify_download_checksum()`: checks the checksum *before* installing.
- **`upgrader_process_complete`** → `clear_cache()`: throws away the cache after a successful update.

The class is instantiated when the plugin loads — with its own version and the gh-pages manifest URL:

```php
new \JPKComHideLoginGitUpdate\JPKComGitPluginUpdater(
    plugin_file:     __FILE__,
    current_version: JPKCOM_EXAMPLE_VERSION,
    manifest_url:    'https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json'
);
```

### Fetching the manifest — with cache and race protection

The heart is `get_remote_manifest()`. It does three things at once: it caches the result for 24 hours, it uses a lock to prevent several concurrent requests from fetching the same manifest in parallel, and it fetches it SSRF-safely.

```php
private function get_remote_manifest(): ?object {
    $remote = get_transient( $this->cache_key );

    if ( false === $remote ) {
        // Race protection: is another fetch already running? Then skip this one.
        $lock_key = $this->cache_key . '_lock';
        if ( get_transient( $lock_key ) ) {
            return null;
        }
        set_transient( $lock_key, true, 30 );   // lock for 30 seconds

        $response = wp_safe_remote_get( $this->manifest_url, [
            'timeout' => 15,
            'headers' => [ 'Accept' => 'application/json' ],
        ] );

        delete_transient( $lock_key );

        if ( is_wp_error( $response )
             || wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return null;   // in WP_DEBUG mode this also logs
        }

        $remote = json_decode( wp_remote_retrieve_body( $response ) );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return null;
        }

        set_transient( $this->cache_key, $remote, DAY_IN_SECONDS );
    }

    return is_object( $remote ) ? $remote : null;
}
```

Three details that make the difference between "works" and "works under load":

- **`wp_safe_remote_get()`** instead of `wp_remote_get()`. The "safe" variant blocks requests to internal/private IP ranges — protection against SSRF, should the manifest URL ever be manipulated.
- **The lock.** On a busy site, several requests can find the cache empty at the same time. Without a lock they'd all fetch the manifest in parallel. The 30-second lock ensures exactly one goes and the others simply skip this cycle.
- **The 24-hour cache** (`DAY_IN_SECONDS`). Each install queries the manifest at most once a day. That's both friendly to the source and more than fresh enough for plugin updates.

### Reporting the update

`check_update()` is almost unspectacular after that — and that's a good thing. Check the cache is populated, compare the version, and on "newer" build an update object:

```php
public function check_update( mixed $transient ): object {
    if ( empty( $transient->checked ) ) {
        return $transient;
    }

    $remote = $this->get_remote_manifest();
    if ( ! $remote || empty( $remote->version ) ) {
        return $transient;
    }

    if ( version_compare( $this->current_version, $remote->version, '<' ) ) {
        $download_url = $remote->download_url ?? '';
        if ( ! empty( $download_url ) && ! wp_http_validate_url( $download_url ) ) {
            return $transient;   // invalid download URL -> offer no update
        }

        $update              = new \stdClass();
        $update->slug        = $this->plugin_slug;
        $update->new_version = sanitize_text_field( $remote->version );
        $update->package     = esc_url_raw( $download_url );   // WP pulls the ZIP from here
        $update->plugin      = plugin_basename( $this->plugin_file );

        $transient->response[ plugin_basename( $this->plugin_file ) ] = $update;
    }

    return $transient;
}
```

Once this `$transient->response` object is set, WordPress shows the familiar "Version 1.3.0 available" with an update button in the backend — indistinguishable from a directory plugin. In parallel, `plugin_info()` (not shown here) fills the "View details" modal from the manifest's `sections`.

## Security: why a URL alone is never enough

A self-hosted update system is a powerful thing — you're telling WordPress: "install whatever lives at this URL." If that URL, the manifest, or the connection to it were compromised, you'd be installing arbitrary code on every connected site. That's why the whole concept stands or falls on one rule: **a download URL alone is no proof of trust.** Only the checksum makes it credible.

### The checksum gate

The most important hook is `upgrader_pre_download`. It fires *before* WordPress installs the package, and is allowed to abort the process with a `WP_Error`. That's exactly where the SHA256 stored in the manifest is checked against the ZIP that was actually downloaded:

```php
public function verify_download_checksum( $reply, string $package, \WP_Upgrader $upgrader ) {
    // Does this download even concern our plugin?
    $remote = $this->get_remote_manifest();
    // (matched against manifest->download_url or the slug – trimmed here)

    if ( ! $remote || empty( $remote->checksum_sha256 ) ) {
        return $reply;   // no checksum in manifest: don't block (backward compatibility)
    }

    // Download the package temporarily and compute the hash
    $temp_file = download_url( $package );
    if ( is_wp_error( $temp_file ) ) {
        return new \WP_Error( 'download_failed', $temp_file->get_error_message() );
    }
    $calculated_hash = hash_file( 'sha256', $temp_file );
    @unlink( $temp_file );

    // Timing-safe comparison
    $expected_hash = strtolower( trim( $remote->checksum_sha256 ) );
    if ( ! is_string( $calculated_hash ) || ! hash_equals( $expected_hash, $calculated_hash ) ) {
        return new \WP_Error(
            'checksum_mismatch',
            __( 'Security check failed: checksums do not match.', 'jpkcom-example' )
        );
    }

    return $reply;   // checksum matches -> allow installation
}
```

The effect: even if an attacker managed to bend the download URL in the manifest toward a tampered ZIP, they'd *additionally* have to store its SHA256 in the same manifest — and even then the question remains how they'd have altered the manifest at all. If the hash doesn't match exactly, the install aborts with a clear error message. Nothing gets installed.

Two subtleties matter here:

- **`hash_equals()`** instead of `===`. The comparison runs timing-safely, so no conclusions about the expected hash can be drawn from how long the comparison takes. For checksums this is more principle than acute threat — but it's the right habit.
- The hash is **normalized** (`strtolower`, `trim`) before comparison, so that harmless whitespace or a case difference in the manifest doesn't get wrongly flagged as tampering.

### Defense in depth

The checksum is the last line of defense, but not the only one. Across the whole class runs the principle of distrusting **every** value read from the manifest until it's validated and sanitized:

- **URLs** are consistently checked via `wp_http_validate_url()` and sanitized with `esc_url_raw()` — the manifest URL itself, every download URL, every banner and icon URL.
- **Text fields** from the manifest (`version`, `author`, `tested` …) run through `sanitize_text_field()` before output, HTML sections through `wp_kses_post()`. The manifest must never write unfiltered HTML into the backend.
- **`wp_safe_remote_get()`** prevents the fetch from being redirected to internal network addresses (SSRF).
- **The environment gate** in the constructor ensures the class doesn't even become active on outdated PHP/WP versions, rather than failing there unpredictably.

None of these measures is spectacular on its own. Together they form a chain in which every link assumes the others might fail — and that's exactly the point.

## Rate-limit independence: why gh-pages instead of the GitHub API

Now to the point that sets this setup apart from many ready-made "GitHub updater" libraries. The obvious way to determine a repo's latest release is the **GitHub REST API** — e.g. `api.github.com/repos/OWNER/REPO/releases/latest`. Many update-checker libraries do exactly that. And that's precisely where the problem is.

The GitHub API limits **unauthenticated** requests to **60 per hour per IP address**. That sounds like a lot, but it isn't:

- On **shared hosting**, dozens or hundreds of WordPress installs often share the same outbound IP. They'd all draw on the same quota of 60.
- Once the limit is exhausted, the API responds with `403` — the update check fails, and *silently*: the site thinks there's no update.
- The obvious "fix" of embedding an API token isn't one: a personal token in shipped plugin code would be a real security leak.

The way out is to bypass the API entirely. **GitHub Pages is not an API endpoint but static hosting via a CDN.** A fetch of

```text
https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json
```

is, to GitHub, the same as fetching any static web page: no token, no auth context, no API rate limit. The same holds for the ZIP download from the release: `github.com/…/releases/download/…` goes through the download infrastructure, not through `api.github.com`. So both steps of the update process — reading the manifest *and* pulling the ZIP — never touch the rate-limited API.

The contrast at a glance:

| | GitHub API path | gh-pages manifest (this setup) |
| --- | --- | --- |
| Endpoint | `api.github.com/...` | static file on a CDN |
| Rate limit (without token) | 60 requests/h **per IP** | effectively none for normal traffic |
| Behavior on shared hosting | shared IP quota → exhausted early | every site gets its answer |
| Token needed for relief | yes (and a risk in the plugin) | no |
| Failure behavior | `403`, update check fails silently | static file, as available as the CDN |

And the 24-hour transient plus the lock from the updater class pile on even more: even if thousands of sites use the same manifest, each queries it at most once a day — and per site, only one request actually goes out when several are concurrent. So the setup is built from the ground up to scale arbitrarily without ever hitting a limit.

## What's deliberately missing — and the limits

An honest making-of also names what the setup does *not* do:

- **No automatic rollback.** If an update fails or a version is buggy, there's no built-in revert switch. The checksum prevents installing *tampered* packages, not a faultily published one. Discipline when tagging remains the actual safeguard.
- **Checksum, not signature.** SHA256 proves the ZIP is unchanged from what the manifest describes — it doesn't cryptographically prove *who* created it. The next stage would be a real signature (e.g. via [Sigstore](https://www.sigstore.dev/) / `cosign` or GPG) that also makes provenance provable. For a self-controlled repo with protected `contents: write`, the checksum is a pragmatic, well-justifiable starting point.
- **Trust in GitHub.** Hosting your release and manifest on GitHub means trusting GitHub's access control. That's the same basis of trust as hosting the code itself — but you should make it consciously (2FA, protected branches, minimal token scopes).
- **The manifest is public.** gh-pages is public. For a commercial plugin with license checks, this route wouldn't be suitable as-is — then you do need a dynamic endpoint that verifies licenses. For freely available plugins that just aren't listed in the directory, public is exactly right.

These omissions are decisions, not oversights: the setup solves *one* problem — convenient, rate-limit-free, checksum-secured auto-updates for self-hosted plugins — and it solves it completely. Anything beyond that would be a different project.

## FAQ

### Do I have to touch code for every new release?

No. The entire flow hangs on a single Git tag. `git tag v1.3.1 && git push --tags` — the rest (ZIP, checksum, release, manifest, deploy) is handled by the pipeline. You maintain the version number in the plugin header and the `README.md` anyway; they're the source the tag and manifest feed from.

### Does this work for themes instead of plugins?

Yes, the principle is identical. WordPress has the theme counterparts `site_transient_update_themes` and `themes_api`. The pipeline and manifest stay practically the same; you just swap the hooks in the updater class for the theme variants.

### What happens if gh-pages is unreachable?

Then `get_remote_manifest()` returns `null`, and `check_update()` simply reports no update — the site keeps running normally, it just doesn't learn about a new version this cycle. On the next fetch (at latest once the 24-hour cache expires) it tries again. So an outage of the update source never takes the site down.

### Is the SHA256 check really necessary if everything runs over HTTPS?

HTTPS secures the *transport* — it guarantees nobody alters the ZIP in transit. But it says nothing about whether the file is the right one at the *origin*. The checksum closes exactly that gap: it binds the installed ZIP to precisely the fingerprint the pipeline computed at build time. Both layers complement each other; neither replaces the other.

### Why the `README.md` as the source for the manifest?

Because it's maintained anyway and comes close to WordPress conventions (description, installation, changelog, FAQ as `##` sections). Maintaining description text twice — once for the repo, once for the backend — is a classic source of stale information. A single source prevents that.

## Further reading

The thinking behind this article — build a tool yourself and expose how it *really* works — also runs through the [making-of of my SEO and GEO tool](https://www.jpkc.com/db/en/blog/making-of-seo-tool/). If you want to go deeper on the security and automation angle, [Configuring Claude Code](https://www.jpkc.com/db/en/blog/claude-code-konfiguration/) is the natural counterpart on hooks, permissions and hardened automation. And the fastest way to understand the setup is to rebuild it on a small plugin of your own: one tag, one `release.yml`, one manifest — and the update button appears.

