From 7b4a9ff5c01a236b8defebf71ca386b90bb34bba Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:37:04 +0200 Subject: [PATCH 1/7] Prepare ForgeFlow 0.10.15 package metadata --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9922dc8..43c45d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.10.14", + "version": "0.10.15", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -123,6 +123,7 @@ "docs/RELEASE_NOTES_0.10.12.md", "docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.14.md", + "docs/RELEASE_NOTES_0.10.15.md", "docs/CURRENT_STATE.md", "docs/MUTATION_MODEL.md", "docs/RELEASING.md", -- 2.54.0 From d8a4f357fb773ab533727dffb9e0e9ded60bb8ef Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:37:17 +0200 Subject: [PATCH 2/7] Add ForgeFlow 0.10.15 release notes --- docs/RELEASE_NOTES_0.10.15.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/RELEASE_NOTES_0.10.15.md diff --git a/docs/RELEASE_NOTES_0.10.15.md b/docs/RELEASE_NOTES_0.10.15.md new file mode 100644 index 0000000..69ba67e --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.15.md @@ -0,0 +1,15 @@ +# ForgeFlow 0.10.15 + +## Veilige exacte workspace-sync en robuustere updates + +- **Workspace Sync** brengt een repository gecontroleerd naar de exacte Gitea-commit zonder lokale wijzigingen stilzwijgend terug naar de server te sturen. Lokale commits krijgen een recovery branch en gewijzigde of niet-getrackte bestanden worden in een expliciete ForgeFlow-quarantaine bewaard. +- Elke quarantaine krijgt een lokaal **Codex review manifest** met bron- en doelcommit, recovery branch, stash-identiteit en betrokken bestanden. Quarantainestashes kunnen niet via de normale ForgeFlow-herstelactie in één keer worden teruggezet; eerst moet de inhoud gericht worden nagekeken. +- ForgeFlow behandelt `forgeflow/recovery-*` branches als **local-only** en weigert ze via de normale pushactie te publiceren, zodat herstelmateriaal niet per ongeluk opnieuw in Gitea terechtkomt. +- De source updater voert checksum- en Git-working-tree-preflight uit **voordat** ForgeFlow de update aan de externe helper overdraagt. Een Git-checkout wordt niet meer destructief met een bronarchief overschreven. +- De Windows binary updater controleert het nieuwe uitvoerbare bestand vóór de ownership handoff, verifieert na update dat ForgeFlow werkelijk blijft draaien en kan bij een mislukte portable update de vorige executable herstellen en opnieuw starten. +- Een geslaagde installer-update waarbij alleen de automatische herstart mislukt, wordt correct als geïnstalleerd gerapporteerd met een duidelijke instructie om ForgeFlow handmatig te starten. + +## Verificatie + +- Managed full validation op de sync/updater-hardening is geslaagd op de exacte feature-head en opnieuw als verplichte pull-requestvalidatie vóór merge. +- De merge naar `main` is uitgevoerd via de beschermde pull-requestflow; de releaseversie wordt afzonderlijk gevalideerd voordat 0.10.15 wordt gepubliceerd. -- 2.54.0 From ca428128d0fb7cc1d0f4a5565d8d42686fa1332d Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:39:26 +0200 Subject: [PATCH 3/7] Allow authenticated Gitea Actions release publishing --- scripts/publish-binary-release.cjs | 114 ++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 28 deletions(-) diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs index b8cc6b2..e6d3f2f 100644 --- a/scripts/publish-binary-release.cjs +++ b/scripts/publish-binary-release.cjs @@ -20,6 +20,15 @@ function safeRepositoryPart(value, label) { return text; } +async function readOptionalConfig(configPath) { + try { + return JSON.parse(await fs.readFile(configPath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + async function api(baseUrl, token, pathname, options = {}) { const response = await fetch(`${baseUrl}/api/v1${pathname}`, { ...options, @@ -51,26 +60,39 @@ app.whenReady().then(async () => { await fs.readFile(path.join(root, "package.json"), "utf8"), ); const configPath = path.join(configuredUserData, "forgeflow-config.json"); - const config = JSON.parse(await fs.readFile(configPath, "utf8")); - if (!config?.gitea?.encryptedToken) { - throw new Error( - `No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`, + const config = await readOptionalConfig(configPath); + const actionsToken = String( + process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "", + ).trim(); + let token = actionsToken; + if (!token) { + if (!config?.gitea?.encryptedToken) { + throw new Error( + `No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`, + ); + } + token = safeStorage.decryptString( + Buffer.from(config.gitea.encryptedToken, "base64"), ); } - const token = safeStorage.decryptString( - Buffer.from(config.gitea.encryptedToken, "base64"), - ); - const baseUrl = normalizeBaseUrl(config.gitea.baseUrl); + const configuredBaseUrl = + process.env.FORGEFLOW_RELEASE_BASE_URL || config?.gitea?.baseUrl; + if (!configuredBaseUrl) { + throw new Error( + "No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.", + ); + } + const baseUrl = normalizeBaseUrl(configuredBaseUrl); const owner = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens", + process.env.FORGEFLOW_RELEASE_OWNER || config?.updates?.owner || "Jens", "Release repository owner", ); const repo = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", + process.env.FORGEFLOW_RELEASE_REPO || config?.updates?.repo || "ForgeFlow", "Release repository name", ); const branch = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main", + process.env.FORGEFLOW_RELEASE_BRANCH || config?.updates?.branch || "main", "Release branch", ); const version = manifest.version; @@ -122,11 +144,16 @@ app.whenReady().then(async () => { } if (release.draft !== true) { - release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ draft: true }), - }); + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: true }), + }, + ); } const binaries = [ path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), @@ -183,27 +210,58 @@ app.whenReady().then(async () => { [`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"], ]) { const bytes = await fs.readFile(path.join(root, "dist", name)); - const existing = (release.assets || []).find((asset) => asset.name === name); - if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" }); + const existing = (release.assets || []).find( + (asset) => asset.name === name, + ); + if (existing) { + await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/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/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 }); - release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded]; + const uploaded = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, + { method: "POST", body: form, timeout: 300_000 }, + ); + release.assets = [ + ...(release.assets || []).filter((asset) => asset.name !== name), + uploaded, + ]; } const requiredAssets = [ - ...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]), + ...binaries.flatMap((binaryPath) => [ + path.basename(binaryPath), + `${path.basename(binaryPath)}.sha256`, + ]), `ForgeFlow-${version}-provenance.json`, `ForgeFlow-${version}-sbom.cdx.json`, `ForgeFlow-${version}-release-manifest.json`, `ForgeFlow-${version}-release-manifest.json.sig`, ]; - const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name)); - if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`); - release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ draft: false }), - }); + const missingAssets = requiredAssets.filter( + (name) => !(release.assets || []).some((asset) => asset.name === name), + ); + if (missingAssets.length) { + throw new Error( + `Release remains draft because required assets are missing: ${missingAssets.join(", ")}`, + ); + } + release = await api( + baseUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: false }), + }, + ); console.log( `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, ); -- 2.54.0 From aac0fa294fc19790dd9751edc2e301745df1c7b7 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:39:59 +0200 Subject: [PATCH 4/7] Automate signed ForgeFlow releases on version bumps --- .gitea/workflows/release.yml | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..3d4e6b1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,88 @@ +name: ForgeFlow signed release + +on: + push: + branches: [main] + paths: + - package.json + workflow_dispatch: + +permissions: + code: read + releases: write + +jobs: + release: + runs-on: windows-native + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Build, sign and publish version bump + shell: powershell + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + FORGEFLOW_RELEASE_BASE_URL: ${{ gitea.server_url }} + FORGEFLOW_RELEASE_OWNER: Jens + FORGEFLOW_RELEASE_REPO: ForgeFlow + FORGEFLOW_RELEASE_BRANCH: main + FORGEFLOW_RELEASE_SIGNING_KEY_PEM: ${{ secrets.FORGEFLOW_RELEASE_SIGNING_KEY_PEM }} + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + + $manifest = Get-Content -LiteralPath "package.json" -Raw | ConvertFrom-Json + $version = [string]$manifest.version + $previousVersion = "" + try { + $previousJson = (& git show "HEAD^:package.json" 2>$null | Out-String) + if ($LASTEXITCODE -eq 0 -and $previousJson.Trim()) { + $previousVersion = [string](ConvertFrom-Json $previousJson).version + } + } catch { + $previousVersion = "" + } + + if ($env:GITEA_EVENT_NAME -eq "push" -and $previousVersion -eq $version) { + Write-Host "package.json changed without a version bump ($version); no release will be published." + exit 0 + } + if ($version -notmatch '^\d+\.\d+\.\d+$') { + throw "ForgeFlow version '$version' is not a stable semantic version." + } + + if ($env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM) { + $privateKeyPath = Join-Path $env:RUNNER_TEMP "forgeflow-release-signing-private.pem" + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($privateKeyPath, $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM, $utf8NoBom) + $env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $privateKeyPath + } + + $effectiveKeyPath = if ($env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY) { + $env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY + } else { + Join-Path $env:APPDATA "forgeflow\release-signing-private.pem" + } + if (-not (Test-Path -LiteralPath $effectiveKeyPath)) { + throw "ForgeFlow release signing key is not provisioned for the windows-native runner. Provision the existing Ed25519 private key as FORGEFLOW_RELEASE_SIGNING_KEY_PEM or at $effectiveKeyPath." + } + if (-not $env:GITEA_TOKEN) { + throw "Gitea Actions did not provide GITEA_TOKEN to the release job." + } + + & cmd.exe /d /s /c "npm ci --no-audit --no-fund" + if ($LASTEXITCODE -ne 0) { throw "npm ci failed." } + & cmd.exe /d /s /c "npx electron --version" + if ($LASTEXITCODE -ne 0) { throw "Electron preflight failed." } + & cmd.exe /d /s /c "npm run check" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release quality gate failed." } + & cmd.exe /d /s /c "npm run dist:win" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Windows build/signing failed." } + & cmd.exe /d /s /c "npm run release:binary" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Gitea release publication failed." } + + Write-Host "ForgeFlow $version was built, signed and published from exact main HEAD $env:GITEA_SHA." -- 2.54.0 From 8d4b1343c608d69a1b75a3b34c90befbcd79367d Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:40:18 +0200 Subject: [PATCH 5/7] Make release trigger context explicit --- .gitea/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3d4e6b1..d8986a1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -25,6 +25,7 @@ jobs: - name: Build, sign and publish version bump shell: powershell env: + GITEA_EVENT_NAME: ${{ gitea.event_name }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} FORGEFLOW_RELEASE_BASE_URL: ${{ gitea.server_url }} FORGEFLOW_RELEASE_OWNER: Jens -- 2.54.0 From 255aec83d473e5b59447896db58292df4e81f623 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:42:39 +0200 Subject: [PATCH 6/7] Preserve publisher settings contract --- scripts/publish-binary-release.cjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs index e6d3f2f..ba556a6 100644 --- a/scripts/publish-binary-release.cjs +++ b/scripts/publish-binary-release.cjs @@ -60,13 +60,13 @@ app.whenReady().then(async () => { await fs.readFile(path.join(root, "package.json"), "utf8"), ); const configPath = path.join(configuredUserData, "forgeflow-config.json"); - const config = await readOptionalConfig(configPath); + const config = (await readOptionalConfig(configPath)) || {}; const actionsToken = String( process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "", ).trim(); let token = actionsToken; if (!token) { - if (!config?.gitea?.encryptedToken) { + if (!config.gitea?.encryptedToken) { throw new Error( `No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`, ); @@ -76,7 +76,7 @@ app.whenReady().then(async () => { ); } const configuredBaseUrl = - process.env.FORGEFLOW_RELEASE_BASE_URL || config?.gitea?.baseUrl; + process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl; if (!configuredBaseUrl) { throw new Error( "No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.", @@ -84,15 +84,15 @@ app.whenReady().then(async () => { } const baseUrl = normalizeBaseUrl(configuredBaseUrl); const owner = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_OWNER || config?.updates?.owner || "Jens", + process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens", "Release repository owner", ); const repo = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_REPO || config?.updates?.repo || "ForgeFlow", + process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", "Release repository name", ); const branch = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_BRANCH || config?.updates?.branch || "main", + process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main", "Release branch", ); const version = manifest.version; -- 2.54.0 From 0c1f473436ea9faef0426ea6b7c140e599848581 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Sun, 30 Aug 2026 00:47:56 +0200 Subject: [PATCH 7/7] Gate releases on the complete Windows quality suite --- .gitea/workflows/release.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d8986a1..35f093c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -79,11 +79,17 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "npm ci failed." } & cmd.exe /d /s /c "npx electron --version" if ($LASTEXITCODE -ne 0) { throw "Electron preflight failed." } - & cmd.exe /d /s /c "npm run check" + & cmd.exe /d /s /c "npm run quality" if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release quality gate failed." } + & cmd.exe /d /s /c "npx playwright install chromium" + if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium installation failed." } + & cmd.exe /d /s /c "npm run test:browser:ci" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow browser acceptance suite failed." } + & cmd.exe /d /s /c "npm audit --omit=dev --audit-level=high" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow production dependency audit failed." } & cmd.exe /d /s /c "npm run dist:win" if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Windows build/signing failed." } & cmd.exe /d /s /c "npm run release:binary" if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Gitea release publication failed." } - Write-Host "ForgeFlow $version was built, signed and published from exact main HEAD $env:GITEA_SHA." + Write-Host "ForgeFlow $version was fully validated, built, signed and published from exact main HEAD $env:GITEA_SHA." -- 2.54.0