DEV Community

Cover image for How I Built a Self-Updater With GitHub Releases
Canercan Demir
Canercan Demir

Posted on

How I Built a Self-Updater With GitHub Releases

I've been building PointArt — a PHP micro-framework modelled after Spring Boot's programming model. Attribute-based routing, dependency injection, an ORM, repositories — all in plain PHP, no Composer required.

The previous articles in this series covered the framework itself and how I turned GitHub into a headless CMS for the docs site. This one is about a different kind of problem: distribution.

PointArt v1.1.0 added CORS and CSRF support. CORS headers are opt-in via .env — useful if you're building an API frontend. CSRF protection is on by default for all POST form requests, with per-route opt-out for webhooks. Both are security features, the kind of thing you actually want users to be running.

But here's the problem: how do users get the update?

There's no composer update (and if it ever comes, it will be totally optional). The framework is just files — you clone it and deploy it. Updating means manually downloading a zip, figuring out which files changed, and copying them over without breaking your own app/ code. In practice, most people don't bother. They stay on whatever version they cloned.

That's a bad place to be when the update contains security features. So I built a self-updater directly into the framework — browser-based, no terminal required, pulls from GitHub Releases.

This article is about how that works: the system design, the tradeoffs, and how it all fits into a codebase with zero dependencies.


The Constraint: Zero Dependencies

Composer is great, but not everyone has it available — shared hosting environments vary a lot, and more importantly, not every project needs it. Optional Composer support is on the roadmap, but zero-dependency will always remain a valid choice. If PHP 8.1+ is available, PointArt runs.

That constraint shapes everything about how the updater had to be built. No CLI helpers. Just what PHP ships with:

  • file_get_contents() with an HTTP context — for hitting the GitHub API and downloading zips
  • ZipArchive — for extracting the release archive
  • copy(), scandir(), mkdir() — for the file sync
  • hash_equals() — for constant-time secret comparison
  • Sessions — for auth state between the login POST and the update page

That's the entire dependency surface. If these exist on a host, the updater works.


The Design

The updater intercepts requests before the application router runs — so it works even if the framework itself is partially broken, which matters when you're about to overwrite framework files.

The full flow:

User visits /pointart/update
    → Not authed? Show login form
    → Authed? Call GitHub API, show version check page
        → Click "Update Now"
            → Download zip from GitHub
            → Backup existing files
            → Sync new files (skip protected paths)
            → Clear route cache
            → Show result
Enter fullscreen mode Exit fullscreen mode

Version Tracking

The installed version is stored in a plain text file: framework/VERSION.

1.1.0
Enter fullscreen mode Exit fullscreen mode

That's it. One line. The updater reads it with file_get_contents, strips whitespace, and extracts the semver string with a regex. If the file doesn't exist, it defaults to 0.0.0 — which means any real release will be considered an update.

public function getCurrentVersion(): string {
    if (!is_file(self::VERSION_FILE)) return '0.0.0';

    $raw = trim((string) file_get_contents(self::VERSION_FILE));
    if ($raw === '') return '0.0.0';

    if (preg_match('/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/', $raw, $matches) === 1) {
        return $matches[0];
    }

    return $raw;
}
Enter fullscreen mode Exit fullscreen mode

Keeping the version in a plain file means it survives updates — it gets overwritten by whatever the new release ships — and it's readable by anything without parsing JSON or querying a database.


Talking to GitHub Releases

The updater doesn't use webhooks or a custom release server. It just calls the public GitHub Releases API:

GET https://api.github.com/repos/Cn8001/PointArt/releases
Enter fullscreen mode Exit fullscreen mode

This returns a JSON array of releases, newest first. Each object has tag_name for the version string, zipball_url to download the archive, body for release notes, and prerelease — a boolean set from the GitHub release UI. The updater iterates and picks the first release matching the selected channel:

foreach ($releases as $data) {
    $isPrerelease = !empty($data['prerelease']);

    if ($channel === 'stable' && $isPrerelease) continue;
    if ($channel === 'dev'    && !$isPrerelease) continue;

    return [
        'version' => $data['tag_name'],
        'zip_url' => $data['zipball_url'],
        'notes'   => $data['body'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

The stable channel skips anything marked as prerelease on GitHub. The dev channel does the opposite — only prereleases. No special tag naming convention required. The prerelease checkbox on the GitHub release UI is the only thing that controls channel membership.

Version comparison uses PHP's built-in version_compare():

if (version_compare($current, $latest['version'], '>=')) {
    // already up to date
}
Enter fullscreen mode Exit fullscreen mode

The File Sync: Safe by Default

This is the part that has to be right. The sync logic walks the extracted release directory and copies files into place — but with two layers of protection.

Protected paths are never touched:

private const PROTECTED = [
    'app',
    '.env',
    '.env.example',
    'cache',
];
Enter fullscreen mode Exit fullscreen mode

app/ is your controllers, models, views. .env is your config. cache/ is runtime state. SQLite files are also skipped anywhere they're found — by extension, not by path:

if (pathinfo($item, PATHINFO_EXTENSION) === 'sqlite') continue;
Enter fullscreen mode Exit fullscreen mode

Every overwritten file is backed up first:

// Backup existing file
if (is_file($dest)) {
    if (!@copy($dest, $bak)) {
        $errors[] = "Failed to backup $rel";
        continue;  // don't overwrite if we couldn't back up
    }
}
// Copy new file
if (!@copy($src, $dest)) {
    $errors[] = "Failed to copy $rel";
}
Enter fullscreen mode Exit fullscreen mode

If the backup fails, the file isn't overwritten. If any errors occur, the result page reports them and the backup directory stays in place at cache/update-backup-{version}/ so you can restore manually.

On a clean update, the backup directory is removed at the end — no leftover clutter.


Post-Update

PointArt scans app/ once and serializes the route + service registry to cache/registry.ser. Every subsequent request reads from that cache. After an update, the cache needs to be cleared so the next request rebuilds it.

The updater calls this directly:

ClassLoader::clearCache();
Enter fullscreen mode Exit fullscreen mode

Which just deletes the file. No fancy invalidation, no versioning. The next request rebuilds from scratch.


Putting It Together

Here's the updater from a user's perspective:

  1. Add two lines to .env
  2. Visit /pointart/update in a browser
  3. Enter the secret
  4. See current version, latest version, release notes
  5. Click Update
  6. Done

From a deployment perspective, you'd enable the updater when you want to check for updates and disable it afterwards. It's not meant to be always-on.


Tradeoffs

What this approach gets right:

  • Works on shared hosting with no CLI access
  • User's code and data are never touched
  • Backup before overwrite — something can always go wrong
  • Stable/dev channels without special tag naming

What it doesn't do:

  • Rollback — you'd have to restore from the backup directory manually
  • Differential updates — always downloads the full release zip
  • Auto-updates on a schedule — it's manual, on demand

For a framework at this scale and distribution model, those tradeoffs are fine. The goal was to make applying an update take two minutes instead of twenty.


PointArt is open source under the Mozilla Public License 2.0. If you want to dig into the full implementation, the repo and documentation are at pointartframework.com.

Top comments (5)

Collapse
 
heim707 profile image
Efe

Congrats Caner!

Collapse
 
cn8001 profile image
Canercan Demir

Thanks a lot!

Collapse
 
huseyin_yontar profile image
Hüseyin Yontar

Good work 🎉

Collapse
 
tuanacosgun profile image
Tuana Coşgun

Well done Canercan, good work👏

Collapse
 
cn8001 profile image
Canercan Demir

Thanks!