Release ForgeFlow 0.8.2 with binary auto-update

This commit is contained in:
NuklearRabbit
2026-07-26 00:58:24 +02:00
parent 4ad698c4eb
commit 3e5e3d2a8b
16 changed files with 1778 additions and 522 deletions
+78
View File
@@ -0,0 +1,78 @@
param(
[Parameter(Mandatory = $true)][string]$BinaryPath,
[Parameter(Mandatory = $true)][string]$ExpectedSha256,
[Parameter(Mandatory = $true)][string]$ExpectedVersion,
[Parameter(Mandatory = $true)][string]$CurrentExecutable,
[Parameter(Mandatory = $true)][string]$Portable,
[Parameter(Mandatory = $true)][int]$ParentPid,
[Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId
)
$ErrorActionPreference = "Stop"
$isPortable = $Portable -eq "True"
function Write-UpdateState {
param([string]$State, [string]$Message = "", [bool]$RestartLaunched = $false)
$payload = [ordered]@{
schemaVersion = 1
updateId = $UpdateId
state = $State
expectedVersion = $ExpectedVersion
installedVersion = if ($State -eq "success") { $ExpectedVersion } else { $null }
message = $Message
restartLaunched = $RestartLaunched
logPath = $LogPath
updatedAt = [DateTime]::UtcNow.ToString("o")
}
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
$temporary = "$StatusPath.$PID.tmp"
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
}
function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
}
try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
if (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { throw "ForgeFlow did not close within 60 seconds." }
}
if ($isPortable) {
Write-UpdateState -State "applying" -Message "Replacing the portable executable."
$backupPath = "$CurrentExecutable.previous"
Copy-Item -LiteralPath $CurrentExecutable -Destination $backupPath -Force
try {
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
} catch {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
throw
}
} else {
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
$installer = Start-Process -FilePath $BinaryPath -ArgumentList "/S" -PassThru -Wait -WindowStyle Hidden
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
}
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
} catch {
Write-Log $_.Exception.Message
$current = $null
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
if ($current.state -ne "rolled-back") { Write-UpdateState -State "failed" -Message $_.Exception.Message }
exit 1
}
+149
View File
@@ -0,0 +1,149 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron");
const root = path.resolve(__dirname, "..");
const configuredUserData =
process.env.FORGEFLOW_USER_DATA ||
path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", path.resolve(configuredUserData));
async function api(baseUrl, token, pathname, options = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options,
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
...(options.headers || {}),
},
signal: AbortSignal.timeout(options.timeout || 180_000),
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok)
throw new Error(
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
);
return data;
}
app.whenReady().then(async () => {
try {
const manifest = JSON.parse(
await fs.readFile(path.join(root, "package.json"), "utf8"),
);
const config = JSON.parse(
await fs.readFile(
path.join(configuredUserData, "forgeflow-config.json"),
"utf8",
),
);
const token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
const baseUrl = String(config.gitea.baseUrl).replace(/\/+$/, "");
const version = manifest.version;
const tag = `v${version}`;
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
}).trim();
const remote = execFileSync(
"git",
["ls-remote", "origin", "refs/heads/main"],
{ cwd: root, encoding: "utf8" },
)
.trim()
.split(/\s+/)[0];
if (commit !== remote)
throw new Error("Local HEAD is not the published origin/main commit.");
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
const body = await fs.readFile(notesPath, "utf8");
let release;
try {
release = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/tags/${encodeURIComponent(tag)}`,
);
} catch (error) {
if (!/HTTP 404/.test(error.message)) throw error;
release = await api(baseUrl, token, "/repos/Jens/ForgeFlow/releases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: commit,
name: `ForgeFlow ${version}`,
body,
draft: false,
prerelease: false,
}),
});
}
const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
];
for (const binaryPath of binaries) {
const binaryName = path.basename(binaryPath);
const binary = await fs.readFile(binaryPath);
const checksumPath = `${binaryPath}.sha256`;
const checksumName = `${binaryName}.sha256`;
const checksum = await fs.readFile(checksumPath);
for (const [name, bytes, type] of [
[binaryName, binary, "application/vnd.microsoft.portable-executable"],
[checksumName, checksum, "text/plain"],
]) {
const existing = (release.assets || []).find(
(asset) => asset.name === name,
);
if (existing && Number(existing.size) === bytes.length) {
console.log(`SKIP ${name} already published`);
continue;
}
if (existing) {
await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{
method: "POST",
body: form,
timeout: 300_000,
},
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
console.log(`PASS published ${name}`);
}
}
console.log(
`PASS ForgeFlow ${version} binary release published for ${commit.slice(0, 7)}`,
);
app.exit(0);
} catch (error) {
console.error(`FAIL ${error.message}`);
app.exit(1);
}
});
+6 -2
View File
@@ -44,6 +44,8 @@ const required = [
"src/shared/deployment-policy.cjs",
"scripts/acceptance.mjs",
"scripts/validate-installed-connections.cjs",
"scripts/publish-binary-release.cjs",
"scripts/write-release-checksums.mjs",
"scripts/generate-source-manifest.mjs",
"setup-windows.ps1",
"START-FORGEFLOW-OVERLAY.ps1",
@@ -51,6 +53,7 @@ const required = [
"build-windows.ps1",
"UPDATE_FROM_0.3.2.md",
"scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1",
"docs/ARCHITECTURE.md",
"docs/SECURITY.md",
"docs/ROADMAP.md",
@@ -58,6 +61,7 @@ const required = [
"docs/ACCEPTANCE.md",
"docs/RELEASE_NOTES_0.8.0.md",
"docs/RELEASE_NOTES_0.8.1.md",
"docs/RELEASE_NOTES_0.8.2.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -96,9 +100,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.8.1")
if (packageJson.version !== "0.8.2")
throw new Error(
`Expected package version 0.8.1, got ${packageJson.version}.`,
`Expected package version 0.8.2, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
+20
View File
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
const binary = await readFile(path.join(root, "dist", name));
const sha256 = createHash("sha256").update(binary).digest("hex");
await writeFile(
path.join(root, "dist", `${name}.sha256`),
`${sha256} ${name}\n`,
"utf8",
);
console.log(`${name}: ${sha256}`);
}