<?php
// Builds signed release packages for the blog's self updater. Command line only.
//
// php tools/build-release.php --keygen [--key PATH]
// Makes a signing key pair once. By default the secret key is written OUTSIDE the blog folder, to
// ~/.tsblog/release.key (%USERPROFILE%\.tsblog\release.key on Windows). Keep it private and backed up.
// The public key is printed and saved next to it as release.key.pub.
// The tool refuses to use a key stored inside a folder that gets published.
//
// php tools/build-release.php --show-key [--key PATH]
// Prints the public key and its fingerprint (the same fingerprint the blog's Updates card shows).
//
// php tools/build-release.php --version 1.0.1 --url https://example.com/blog-updates --key release.key
// php tools/build-release.php --version 1.0.1 --github OWNER/REPO --key release.key (for a GitHub release)
//
// Changing keys: sign with the OLD key and bake in the NEW one, so blogs that trust the old key accept it:
// php tools/build-release.php --version 1.1.0 --url … --key OLD.key --rotate-to NEW.key
// [--notes "What changed"] [--notes-file CHANGES.txt] [--min-php 8.1.0] [--out dist]
// Writes to dist/:
// tsblog-1.0.1.zip the update package (what the updater downloads)
// --bridge leaves theme/themestrap/ out of the UPDATE package (the fresh install still has it). Use it for the
// first release after a blog's updater learns about a new folder: older updaters reject files in folders
// they do not know. Publish the bridge first, then a normal release that includes the new folder.
// tsblog-1.0.1-install.zip a fresh install, with the address and key already built in
// latest.json the manifest. Upload all three files to --url
//
// Needs the zip PHP extension. The sodium extension is used when present; otherwise lib/vendor/sodium_compat is.
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
$root = dirname(__DIR__);
$opt = getopt('', ['keygen', 'show-key', 'rotate-to:', 'allow-new-key', 'github:', 'version:', 'url:', 'key:', 'notes:', 'notes-file:', 'min-php:', 'out:', 'bridge']);
$fail = function (string $m) { fwrite(STDERR, "Error: $m\n"); exit(1); };
$home = getenv('USERPROFILE') ?: (getenv('HOME') ?: '');
$defaultKey = ($home !== '' ? $home . DIRECTORY_SEPARATOR . '.tsblog' . DIRECTORY_SEPARATOR : '') . 'release.key';
// Folders whose contents are published in every update. A secret must never be anywhere in these.
$published = ['lib', 'assets', 'tools', 'docs', 'theme/default', 'theme/themestrap'];
$keyGuard = function (string $keyPath) use ($root, $published, $fail, $defaultKey) {
$dir = realpath(dirname($keyPath));
if ($dir === false) return;
$full = $dir . DIRECTORY_SEPARATOR . basename($keyPath);
$rootReal = realpath($root);
foreach ($published as $d) {
$pub = realpath("$root/$d");
if ($pub !== false && strpos($full . DIRECTORY_SEPARATOR, $pub . DIRECTORY_SEPARATOR) === 0) {
$fail("The key file ($full) is inside $d/, which is published to every blog. Keep it outside the blog folder, for example at $defaultKey");
}
}
if ($rootReal !== false && strpos($full, $rootReal . DIRECTORY_SEPARATOR) === 0) {
fwrite(STDERR, "Warning: the key file is inside the blog folder. It is not published, but it could be committed to git. Prefer $defaultKey\n");
}
};
// Without the sodium extension, the bundled pure PHP version is used (slower, same result)
if (!function_exists('sodium_crypto_sign_keypair')) {
$fallback = $root . '/lib/vendor/sodium_compat/autoload.php';
if (!is_file($fallback)) $fail('The sodium PHP extension is required, and lib/vendor/sodium_compat is missing.');
require $fallback;
}
if (!class_exists('ZipArchive')) $fail('The zip PHP extension is required.');
if (isset($opt['keygen'])) {
$path = $opt['key'] ?? $defaultKey;
if (!is_dir(dirname($path)) && !@mkdir(dirname($path), 0700, true)) $fail('Could not create the folder ' . dirname($path));
$keyGuard($path);
if (file_exists($path)) $fail("$path already exists. Move it away first; overwriting it would lock you out of publishing.");
$pair = sodium_crypto_sign_keypair();
file_put_contents($path, base64_encode(sodium_crypto_sign_secretkey($pair)) . "\n");
@chmod($path, 0600);
$pub = base64_encode(sodium_crypto_sign_publickey($pair));
file_put_contents($path . '.pub', $pub . "\n");
echo "Secret key written to $path (keep it private and make a backup).\nPublic key: $pub\n";
echo "Fingerprint: " . substr(hash('sha256', sodium_crypto_sign_publickey($pair)), 0, 16) . "\n";
exit(0);
}
// A short fingerprint of a public key. The blog's Updates card shows the same one for the key it trusts.
$keyId = fn(string $rawPublic) => substr(hash('sha256', $rawPublic), 0, 16);
// Accepts a secret key file or a .pub file and returns the raw public key
$loadPublic = function (string $path) use ($fail) {
if (!is_file($path)) $fail("Key file $path not found.");
$raw = base64_decode(trim((string) file_get_contents($path)), true);
if ($raw !== false && strlen($raw) === SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) return sodium_crypto_sign_publickey_from_secretkey($raw);
if ($raw !== false && strlen($raw) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return $raw;
$fail("$path is not a valid key file.");
};
if (isset($opt['show-key'])) {
$kf = $opt['key'] ?? $defaultKey;
$pub = $loadPublic($kf);
echo "Key file: $kf\nPublic key: " . base64_encode($pub) . "\nFingerprint: " . $keyId($pub) . "\n";
exit(0);
}
$version = $opt['version'] ?? $fail('Give --version, for example --version 1.0.1');
if (isset($opt['github'])) {
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $opt['github'])) $fail('--github should look like OWNER/REPO');
$opt['url'] = "https://github.com/{$opt['github']}/releases/latest/download";
}
$url = rtrim($opt['url'] ?? $fail('Give --url (the https folder you will upload the files to) or --github OWNER/REPO.'), '/');
$url = preg_replace('#/latest\.json$#', '', $url);
$keyFile = $opt['key'] ?? $defaultKey;
$out = rtrim($opt['out'] ?? 'dist', '/');
if (!preg_match('/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/', $version)) $fail('The version should look like 1.2.3.');
if (!preg_match('#^https?://#', $url)) $fail('--url must start with https://');
if (!is_file($keyFile)) $fail("Key file $keyFile not found. Make one with --keygen.");
$keyGuard($keyFile);
$secret = base64_decode(trim((string) file_get_contents($keyFile)), true);
if ($secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) $fail('That is not a valid secret key file.');
$signerPublic = sodium_crypto_sign_publickey_from_secretkey($secret);
$signerId = $keyId($signerPublic);
// The key blogs will trust after installing this release: normally the signing key, or the new one when rotating
$bakePublic = isset($opt['rotate-to']) ? $loadPublic($opt['rotate-to']) : $signerPublic;
$public = base64_encode($bakePublic);
// Remember which key each update address is signed with, so a different key cannot be used by accident.
// (A blog only accepts releases signed with the key it already trusts, so a changed key breaks every blog.)
$histFile = $home !== '' ? dirname($defaultKey) . DIRECTORY_SEPARATOR . 'history.json' : null;
$history = $histFile && is_file($histFile) ? (json_decode((string) file_get_contents($histFile), true) ?: []) : [];
$trusted = $history[$url]['trusts'] ?? null;
if ($trusted !== null && $trusted !== $signerId && !isset($opt['allow-new-key'])) {
$fail("This update address ($url) was last published with key $trusted, but you are signing with key $signerId.\n"
. "Blogs trust key $trusted, so they would reject this release (\"The update's signature is not valid\").\n"
. " - Use the original key: --key PATH-TO-THE-KEY-WITH-FINGERPRINT-$trusted\n"
. " - Change keys properly: --key OLD.key --rotate-to NEW.key (signed with the old key, carries the new one)\n"
. " - Start over (blogs must then be updated by hand once): --allow-new-key");
}
$notes = isset($opt['notes-file']) ? trim((string) file_get_contents($opt['notes-file'])) : (string) ($opt['notes'] ?? '');
if (!is_dir($out) && !mkdir($out, 0775, true)) $fail("Could not create $out");
// What goes in an update. Never content, uploads, data, config.php, .htaccess, .user.ini or other themes.
function files_under(string $root, array $dirs, array $top, array &$suspect, array $skipReal): array
{
$list = [];
foreach ($top as $f) if (is_file("$root/$f")) $list[] = $f;
foreach ($dirs as $d) {
if (!is_dir("$root/$d")) continue;
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("$root/$d", FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if (!$f->isFile()) continue;
$rel = substr($f->getPathname(), strlen($root) + 1);
$rel = str_replace('\\', '/', $rel);
if (preg_match('#(^|/)(\.DS_Store|Thumbs\.db)$#', $rel) || substr($rel, -4) === '.tmp') continue;
// Release output and publisher only tooling never ships
if (preg_match('#(^|/)dist(/|$)#', $rel) || $rel === 'tools/build-release.php') continue;
$real = realpath($f->getPathname());
foreach ($skipReal as $s) if ($real !== false && $s !== false && strpos($real . DIRECTORY_SEPARATOR, $s . DIRECTORY_SEPARATOR) === 0) continue 2;
// Anything that looks like a secret is a stop, not a quiet skip, so the mistake gets noticed
if (preg_match('#(\.(key|pem|pfx|p12)|\.pub)$#i', $rel) || preg_match('#(^|/)\.env(\.|$)#', $rel) || preg_match('#(^|/)(id_rsa|id_ed25519)#', $rel)) { $suspect[] = $rel; continue; }
$list[] = $rel;
}
}
sort($list);
return $list;
}
$suspect = [];
if (realpath($out) === realpath($root)) $fail('--out must be a separate folder, not the blog folder itself.');
$update = files_under($root, $published, ['index.php', 'config.sample.php', 'README.md'], $suspect, [realpath($out)]);
$update = array_values(array_diff($update, ['release.json', 'tools/build-release.php']));
if ($suspect) $fail("These files look like keys or secrets and sit in a folder that is published, so nothing was built:\n - " . implode("\n - ", $suspect) . "\nMove them out of the blog folder, then run this again.");
// lib/version.php carries the version, update address and public key
$vphp = (string) file_get_contents("$root/lib/version.php");
$bake = function (string $src) use ($version, $url, $public) {
$src = preg_replace("/const TS_VERSION\s*=\s*'[^']*';/", "const TS_VERSION = '$version';", $src, 1, $n1);
$src = preg_replace("/const TS_UPDATE_URL\s*=\s*'[^']*';/", "const TS_UPDATE_URL = '$url/latest.json';", $src, 1, $n2);
$src = preg_replace("/const TS_UPDATE_KEY\s*=\s*'[^']*';/", "const TS_UPDATE_KEY = '$public';", $src, 1, $n3);
if ($n1 + $n2 + $n3 !== 3) { fwrite(STDERR, "Error: lib/version.php does not have the expected lines.\n"); exit(1); }
return $src;
};
$contents = [];
foreach ($update as $rel) $contents[$rel] = $rel === 'lib/version.php' ? $bake($vphp) : (string) file_get_contents("$root/$rel");
// Last line of defense: the secret key itself must not appear in anything that is about to ship
foreach ($contents as $rel => $data) {
if (strpos($data, base64_encode($secret)) !== false || strpos($data, $secret) !== false || strpos($data, bin2hex($secret)) !== false) {
$fail("The secret signing key appears inside $rel. Nothing was built. Remove it and consider the key exposed if it was ever published.");
}
}
// Refuse to ship code that does not parse
foreach ($contents as $rel => $data) {
if (substr($rel, -4) !== '.php') continue;
try { token_get_all($data, TOKEN_PARSE); } catch (ParseError $e) { $fail("$rel has a syntax error on line " . $e->getLine()); }
}
$hashes = [];
foreach ($contents as $rel => $data) $hashes[$rel] = hash('sha256', $data);
ksort($hashes);
$release = json_encode(['name' => 'Themestrap Blog', 'version' => $version, 'released' => date('Y-m-d'), 'files' => $hashes], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
function write_zip(string $path, array $contents, string $release, array $extra = []): void
{
@unlink($path);
$z = new ZipArchive();
if ($z->open($path, ZipArchive::CREATE) !== true) { fwrite(STDERR, "Error: cannot write $path\n"); exit(1); }
foreach ($contents as $rel => $data) $z->addFromString("tsblog/$rel", $data);
$z->addFromString('tsblog/release.json', $release);
foreach ($extra as $rel => $data) $z->addFromString("tsblog/$rel", $data);
$z->close();
}
$updateZip = "$out/tsblog-$version.zip";
$bridge = isset($opt['bridge']);
$upContents = $contents;
$upRelease = $release;
if ($bridge) {
$upContents = array_filter($contents, fn($rel) => strpos($rel, 'theme/themestrap/') !== 0, ARRAY_FILTER_USE_KEY);
$h = [];
foreach ($upContents as $rel => $data) $h[$rel] = hash('sha256', $data);
ksort($h);
$upRelease = json_encode(['name' => 'Themestrap Blog', 'version' => $version, 'released' => date('Y-m-d'), 'files' => $h], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
write_zip($updateZip, $upContents, $upRelease);
// A fresh install also carries the starter posts, the private folders and the server files
$extra = [];
foreach (['.htaccess', '.user.ini', '.gitignore'] as $f) if (is_file("$root/$f")) $extra[$f] = (string) file_get_contents("$root/$f");
foreach (['content/posts/welcome-to-your-blog.md', 'content/posts/about.md', 'data/.gitkeep', 'uploads/.gitkeep'] as $f) {
$extra[$f] = is_file("$root/$f") ? (string) file_get_contents("$root/$f") : '';
}
write_zip("$out/tsblog-$version-install.zip", $contents, $release, $extra);
$sha = hash_file('sha256', $updateZip);
$sig = base64_encode(sodium_crypto_sign_detached("tsblog|$version|$sha", $secret));
$manifest = [
'name' => 'Themestrap Blog', 'version' => $version, 'released' => date('Y-m-d'), 'min_php' => $opt['min-php'] ?? '8.1.0',
'zip' => "$url/tsblog-$version.zip", 'sha256' => $sha, 'size' => filesize($updateZip), 'signature' => $sig, 'key_id' => $signerId, 'notes' => $notes,
];
file_put_contents("$out/latest.json", json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
if ($histFile) {
$history[$url] = ['trusts' => $keyId($bakePublic), 'version' => $version];
if (!is_dir(dirname($histFile))) @mkdir(dirname($histFile), 0700, true);
@file_put_contents($histFile, json_encode($history, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
echo "Built version $version in $out/\n";
echo "Signed with key $signerId" . (isset($opt['rotate-to']) ? "; blogs will trust key " . $keyId($bakePublic) . " from this release on" : "") . "\n";
echo " tsblog-$version.zip update package (" . count($upContents) . " files" . ($bridge ? ", bridge: without theme/themestrap/" : "") . ")\n";
echo " tsblog-$version-install.zip fresh install with the update address and key built in\n";
echo " latest.json manifest, signed\n";
echo "Upload all three files to $url/\n";
echo "Address for Settings → Updates (already built into the install zip): $url/latest.json\n";
if (strpos($url, '://github.com/') !== false) echo "On GitHub: create a release, attach the three files, and publish it (not as a draft). The short address https://github.com/" . preg_replace('#^https://github.com/([^/]+/[^/]+)/.*#', '$1', $url) . " also works in Settings.\n";