release: bridge legacy installations to ForgeFlow-Public
Managed validation / full (pull_request) Successful in 27s
ChatGPT validation / quality (push) Successful in 21m36s

This commit is contained in:
NuklearRabbit committed 2026-09-30 23:02:50 +02:00
1 parent 1192d75d15
commit 8769f07539
16 files changed
+339 -222

No files matched your search

+11 -3
View File
@@ -32,7 +32,7 @@ const DEFAULT_CONFIG = {
favorites: [],
updates: {
owner: 'Jens',
repo: 'ForgeFlow',
repo: 'ForgeFlow-Public',
branch: 'main',
autoCheck: true,
lastCheckedAt: null
@@ -124,7 +124,15 @@ class ConfigStore {
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
updates: {
...DEFAULT_CONFIG.updates,
...(source.updates || {}),
// Move existing installations off the legacy endpoint while preserving
// a separately configured update repository.
...((source.updates?.owner ?? 'Jens') === 'Jens' && source.updates?.repo === 'ForgeFlow' && (source.updates?.branch ?? 'main') === 'main'
? { repo: 'ForgeFlow-Public' }
: {})
},
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
@@ -375,7 +383,7 @@ class ConfigStore {
async setUpdatePreferences(updates) {
const next = { ...this.data.updates, ...(updates || {}) };
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100);
next.repo = String(next.repo || 'ForgeFlow-Public').trim().slice(0, 100);
next.branch = assertBranchName(next.branch || 'main');
next.autoCheck = next.autoCheck !== false;
this.data.updates = next;
+21 -5
View File
@@ -265,16 +265,28 @@ class UpdateService {
"Update repository owner",
);
const repo = safeRepositoryPart(
settings.repo || "ForgeFlow",
settings.repo || "ForgeFlow-Public",
"Update repository name",
);
const branchName = String(settings.branch || "main").trim();
const branch = await this.gitea.getBranch(owner, repo, branchName);
const remoteSha =
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
let remoteSha;
let releaseTag = null;
if (this.appInfo.packaged) {
const release = await this.gitea.getLatestRelease(owner, repo);
if (!release || release.draft || release.prerelease) {
const error = new Error("The configured update repository has no published stable Windows release yet.");
error.code = "BINARY_RELEASE_NOT_FOUND";
throw error;
}
remoteSha = release.target_commitish;
releaseTag = release.tag_name;
} else {
const branch = await this.gitea.getBranch(owner, repo, branchName);
remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
}
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
throw new Error(
"Gitea did not return a full commit SHA for the update branch.",
"Gitea did not return a full commit SHA for the update source.",
);
const file = await this.gitea.getRepositoryFile({
@@ -294,6 +306,9 @@ class UpdateService {
"The configured update repository is not a ForgeFlow source repository.",
);
const remoteVersion = String(manifest.version || "").trim();
if (releaseTag && releaseTag !== `v${remoteVersion}` && releaseTag !== remoteVersion) {
throw new Error("The published release tag does not match its source version.");
}
const currentVersion = String(this.appInfo.version || "").trim();
const available = isNewerVersion(remoteVersion, currentVersion);
const result = {
@@ -301,6 +316,7 @@ class UpdateService {
owner,
repo,
branch: branchName,
releaseTag,
currentVersion,
remoteVersion,
remoteSha,
+1 -1
View File
@@ -162,7 +162,7 @@
],
updates: {
owner: "Jens",
repo: "ForgeFlow",
repo: "ForgeFlow-Public",
branch: "main",
autoCheck: true,
lastCheckedAt: null,
+1 -1
View File
@@ -762,7 +762,7 @@ function renderSettings() {
const servers = state.servers || [];
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty only when keeping the same server" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow-Public")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow-Public")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `${update.releaseTag ? `Release ${escapeHtml(update.releaseTag)}` : `Branch ${escapeHtml(update.branch)}`} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
+27
View File
@@ -0,0 +1,27 @@
'use strict';
function windowsReleaseAssetNames(version) {
return [
...['Setup', 'Portable'].flatMap((kind) => {
const name = `ForgeFlow-${kind}-${version}-win-x64.exe`;
return [name, `${name}.sha256`];
}),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`
];
}
function existingReleaseState(release, version, commit) {
if (release.target_commitish !== commit) {
throw new Error(`Release v${version} already targets ${release.target_commitish}; refusing assets from ${commit}. Bump the version for a new source commit.`);
}
if (release.draft) return 'draft';
const names = new Set((release.assets || []).map((asset) => asset.name));
const missing = windowsReleaseAssetNames(version).filter((name) => !names.has(name));
if (missing.length) throw new Error(`Published release v${version} is missing: ${missing.join(', ')}.`);
return 'published';
}
module.exports = { windowsReleaseAssetNames, existingReleaseState };