Release ForgeFlow 0.8.2 with binary auto-update
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user