From 04cbfccbdb864bb3b0b98a9c1b06cf90af2a9c3a Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Wed, 30 Sep 2026 21:27:28 +0000 Subject: [PATCH] Publish curated ForgeFlow source from 63022ccf9a37242d573297c8ce7f15db61acc34e --- .gitea/workflows/release.yml | 25 ++++++++++- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 71 ++++++++++++++++++------------ README.md | 10 ++--- docs/RELEASE_NOTES_0.10.16.md | 11 +++++ docs/RELEASING.md | 2 + docs/UPDATING.md | 10 ++--- package-lock.json | 4 +- package.json | 3 +- scripts/publish-binary-release.cjs | 22 +++------ scripts/verify.mjs | 5 ++- src/main/config-store.cjs | 14 ++++-- src/main/update-service.cjs | 26 ++++++++--- src/renderer/mock-bridge.js | 2 +- src/renderer/views.js | 2 +- src/shared/release-policy.cjs | 27 ++++++++++++ tests/config-store.test.mjs | 14 ++++++ tests/release-policy.test.mjs | 21 +++++++++ tests/update-service.test.mjs | 39 +++++++++++++--- 19 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.10.16.md create mode 100644 src/shared/release-policy.cjs create mode 100644 tests/release-policy.test.mjs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ba36ad7..0e4c777 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,16 +1,23 @@ name: ForgeFlow signed release on: + push: + branches: [main] workflow_dispatch: permissions: code: read releases: write +concurrency: + group: forgeflow-signed-release + cancel-in-progress: false + jobs: release: if: ${{ gitea.repository == 'Jens/ForgeFlow-Public' }} runs-on: windows-native + timeout-minutes: 90 steps: - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -19,7 +26,8 @@ jobs: with: node-version: 22 cache: npm - - name: Validate version bump and build release artifacts + - name: Select versioned release + id: preflight shell: powershell env: GITEA_EVENT_NAME: ${{ gitea.event_name }} @@ -40,12 +48,24 @@ jobs: } 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." + Write-Host "Source changed without a version bump ($version); no release will be published." + "should_release=false" | Out-File -FilePath $env:GITEA_OUTPUT -Encoding ascii -Append exit 0 } if ($version -notmatch '^\d+\.\d+\.\d+$') { throw "ForgeFlow version '$version' is not a stable semantic version." } + if (-not (Test-Path -LiteralPath "PUBLIC_SOURCE_MANIFEST.json")) { + throw "A signed release must be built from the curated public source." + } + "should_release=true" | Out-File -FilePath $env:GITEA_OUTPUT -Encoding ascii -Append + + - name: Build and validate release artifacts + if: ${{ steps.preflight.outputs.should_release == 'true' }} + shell: powershell + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest & cmd.exe /d /s /c "npm ci --no-audit --no-fund" if ($LASTEXITCODE -ne 0) { throw "npm ci failed." } @@ -65,6 +85,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." } - name: Sign and publish validated artifacts + if: ${{ steps.preflight.outputs.should_release == 'true' }} shell: powershell env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index c9e17cf..4cee457 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `db8c79fe5c6c08f4971437d5893c390fd4e45de5`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `63022ccf9a37242d573297c8ce7f15db61acc34e`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index edfeaab..c58c651 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "db8c79fe5c6c08f4971437d5893c390fd4e45de5", + "sourceRevision": "63022ccf9a37242d573297c8ce7f15db61acc34e", "files": [ { "path": ".gitattributes", @@ -14,8 +14,8 @@ }, { "path": ".gitea/workflows/release.yml", - "sha256": "51ce6b5cc8c8cc5b871dcdecca28664e2b8c9f948b6b989389cc22d9b5f943c1", - "bytes": 4854 + "sha256": "8d0f2a6606d7494d2a3bf293d09925380f5c6a2340e418989c5fb52815da2032", + "bytes": 5651 }, { "path": ".gitignore", @@ -64,8 +64,8 @@ }, { "path": "README.md", - "sha256": "56beb22de0ddda207d5bdcb906c549f6fb0ddc47795ebc653fafa5bb8bf6b8d4", - "bytes": 5549 + "sha256": "cf1559c01c61e69d98d2de9554f042d7e940245bfbb7dc58924576c1009b0aab", + "bytes": 5699 }, { "path": "SECURITY.md", @@ -242,6 +242,11 @@ "sha256": "18f34f2fd3650e98f7d2fd63e787894a294585a7e93fbf6b877663ee9d66ec51", "bytes": 1856 }, + { + "path": "docs/RELEASE_NOTES_0.10.16.md", + "sha256": "56c93642ed59a30dc3433f1aec54150423241f59cf8ddcab2bf7e9ce4e91b477", + "bytes": 1051 + }, { "path": "docs/RELEASE_NOTES_0.10.2.md", "sha256": "8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c", @@ -454,8 +459,8 @@ }, { "path": "docs/RELEASING.md", - "sha256": "1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3", - "bytes": 2242 + "sha256": "f05d671c8b81405583a234bd449c2333c6abb486f17002da80b34dbed113ba8a", + "bytes": 2797 }, { "path": "docs/ROADMAP.md", @@ -494,8 +499,8 @@ }, { "path": "docs/UPDATING.md", - "sha256": "485828f99531e188629d0f25dc2905b9ef99a9f08ccbb6ab051530398266c01c", - "bytes": 4443 + "sha256": "40ac4dff2cebda1d9a552ac89434ac95bf28c46c2a980afc828dc3198e26c50e", + "bytes": 4744 }, { "path": "docs/screenshots/deploy-confirmation.png", @@ -584,13 +589,13 @@ }, { "path": "package-lock.json", - "sha256": "163c276240d548fd885b68b85475b92de4431b1313da89edbdd811a32452847c", + "sha256": "5fdb7dea5720d66ef63c02151b0a69ce1e02fb3e1d7c54743fdf7a1b45e5c44c", "bytes": 179808 }, { "path": "package.json", - "sha256": "d2c1e8b55fe0a89145b64c0eccd50b830e99324d99178f448ef0f0a57066bae1", - "bytes": 6330 + "sha256": "84f1c67703c8ddb7af560393f9c4cefa8026a10283567912e44c32686ace31fa", + "bytes": 6369 }, { "path": "playwright.config.mjs", @@ -644,8 +649,8 @@ }, { "path": "scripts/publish-binary-release.cjs", - "sha256": "19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168", - "bytes": 9358 + "sha256": "1bd68c9c6155d00d87d57e1afd7198940e0496fb585f84b3037aee923b3857c9", + "bytes": 9136 }, { "path": "scripts/serve-demo.mjs", @@ -679,8 +684,8 @@ }, { "path": "scripts/verify.mjs", - "sha256": "41581e5c70e079775125e62509312f2c334b960590fc5bad4be71931c6126fcd", - "bytes": 22915 + "sha256": "c7cc83b222315ad5f651c7e5c3a25058a15debc8fb1e2b0a8f2d8de0f768d920", + "bytes": 22950 }, { "path": "scripts/write-release-checksums.mjs", @@ -699,8 +704,8 @@ }, { "path": "src/main/config-store.cjs", - "sha256": "e3af59fafa497d032541979bea5fe2b98187b1ced619c567d15ae79ee0904c1f", - "bytes": 34629 + "sha256": "5363a32151109a8b2763b1d42a4aae60130906af042791f5700ecd3e49013765", + "bytes": 35003 }, { "path": "src/main/configuration-backup.cjs", @@ -874,8 +879,8 @@ }, { "path": "src/main/update-service.cjs", - "sha256": "29b8c5eca83b0e89c7d0716945b5316aac43947b5387e89562d9a024ebc4663c", - "bytes": 27204 + "sha256": "e95c33e4cda06e75e10baea8bf47107cfcd3c6e36c576afa65a94bd54c247dc3", + "bytes": 27908 }, { "path": "src/renderer/actions/command.js", @@ -959,8 +964,8 @@ }, { "path": "src/renderer/mock-bridge.js", - "sha256": "98f4d136c56b4a4e681f6ed77c34e18be69f0f87074520c1be8210f3729d59bb", - "bytes": 21355 + "sha256": "eff6ea1443ea0a8c19151d715b8e96e1d2b688d32f5906d69590c71809525bf8", + "bytes": 21362 }, { "path": "src/renderer/mock-deployment-bridge.js", @@ -984,8 +989,8 @@ }, { "path": "src/renderer/views.js", - "sha256": "51b6499c4cbee2d25baa86cc6f0966646dce31319ebb5846c4006d2624ee7610", - "bytes": 112935 + "sha256": "b5dacd043c1e8f06f9ab86f652bb94523f5e1ac9e3873e0947c14ae7531251ea", + "bytes": 113019 }, { "path": "src/shared/clone-target.cjs", @@ -1002,6 +1007,11 @@ "sha256": "029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f", "bytes": 3057 }, + { + "path": "src/shared/release-policy.cjs", + "sha256": "59b4c20d3cfdecdeb8f2126b5e525f2ae99b26a53a3914a2a7a63bbc1cf5643d", + "bytes": 1099 + }, { "path": "src/shared/repository-match.cjs", "sha256": "2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75", @@ -1064,8 +1074,8 @@ }, { "path": "tests/config-store.test.mjs", - "sha256": "8aa8789a984769b377f719abca42a428fffc4f89b3997d57184a8073d24121e2", - "bytes": 18325 + "sha256": "3bdd45107f0bdba18d608932aabea56ca11224e41578f2de2c93b1805175f16a", + "bytes": 19224 }, { "path": "tests/configuration-backup.test.mjs", @@ -1177,6 +1187,11 @@ "sha256": "0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3", "bytes": 6435 }, + { + "path": "tests/release-policy.test.mjs", + "sha256": "6f26ae12a39a4711cf699f1a68e8f673e1e53e2cc34f5a6f362a66ae15ba9d1a", + "bytes": 1229 + }, { "path": "tests/renderer-workflow.test.mjs", "sha256": "1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e", @@ -1244,8 +1259,8 @@ }, { "path": "tests/update-service.test.mjs", - "sha256": "2357ce799b38edae940f6bb5f047d0b9d013d88ce47a9bed0416a41e6af3d5f8", - "bytes": 30648 + "sha256": "a0a8b8ae8c13e74f079279f7f36f255c58995b6c95fdbec687d38befeb7dcd1f", + "bytes": 32106 }, { "path": "tests/validation.test.mjs", diff --git a/README.md b/README.md index 461e895..6ece235 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ForgeFlow is a Windows desktop application for teams that use Git, Gitea and self-hosted Docker or Unraid servers. It brings local changes, remote commits and the exact revision running on a server into one workspace, then guides review, commit, push, deployment and verification. -> **Public-source edition:** This repository contains reviewed source files from a private canonical repository. It does not yet host signed Windows releases. `PUBLIC_SOURCE_MANIFEST.json` identifies the exact source revision and file hashes; private Git history is not included. +> **Public-source edition:** This repository contains reviewed source files from a private canonical repository. Stable Windows releases are built from this curated source and carry an Ed25519-signed publisher manifest. `PUBLIC_SOURCE_MANIFEST.json` identifies the exact source revision and file hashes; private Git history is not included. -**Downloads:** Existing signed Windows releases remain at [the legacy ForgeFlow release page](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) during the public-repository transition. The [curated public source](https://gitea.itworx.tech/Jens/ForgeFlow-Public) is separate; it will become the release destination only after signing and update compatibility are verified. +**Downloads:** Get current Windows releases from [ForgeFlow-Public](https://gitea.itworx.tech/Jens/ForgeFlow-Public/releases/latest). The [legacy release page](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) stays available while older installations move to the new update endpoint. ## See the workflow @@ -37,12 +37,12 @@ ForgeFlow distinguishes a matching commit from a healthy deployment. It keeps in ### Install the Windows app -1. Download an installer or portable executable from the [published release page](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest). +1. Download an installer or portable executable from the [published release page](https://gitea.itworx.tech/Jens/ForgeFlow-Public/releases/latest). 2. Launch ForgeFlow and complete the setup wizard. 3. Add your Gitea URL, a token with the required repository permissions, and the local folders to scan. 4. Optionally add a Docker or Unraid host, then use **Scan servers** to review detected workloads. -The packaged updater checks a release against the exact remote commit, its SHA-256 checksum and the embedded Ed25519 publisher key. Existing installations continue to use the legacy release endpoint until the migration is qualified. +The packaged updater checks a release against the exact published release commit, its SHA-256 checksum and the embedded Ed25519 publisher key. Existing installations receive the 0.10.16 bridge release from the legacy endpoint; after installation, the default update endpoint changes to ForgeFlow-Public. ### Explore with example data @@ -81,6 +81,6 @@ npm run acceptance `npm run check` performs source verification, linting and tests. Browser acceptance and Windows packaging are separate release gates. `src/main/` contains desktop services and IPC, `src/renderer/` contains the UI, `src/shared/` holds shared policies, `scripts/` contains validation and release tooling, and `tests/` covers core behavior. -The private canonical repository publishes a reviewed, content-only snapshot to `ForgeFlow-Public`. Its manifest records the source revision; private Git history and operational evidence are excluded. Binary release publication remains manual during the endpoint transition. +The private canonical repository publishes a reviewed, content-only snapshot to `ForgeFlow-Public`. Its manifest records the source revision; private Git history and operational evidence are excluded. A version change in the curated public repository starts the Windows quality, build, signing and release workflow. Source-only changes do not publish a new binary version. ForgeFlow is available under the [MIT License](LICENSE). Report security issues through [SECURITY.md](SECURITY.md). diff --git a/docs/RELEASE_NOTES_0.10.16.md b/docs/RELEASE_NOTES_0.10.16.md new file mode 100644 index 0000000..93ea7e9 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.16.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.10.16 + +## Signed releases from the public source repository + +- Packaged updates follow the exact published release commit, so later documentation or source-only changes do not invalidate the signed executable. +- New installations check `Jens/ForgeFlow-Public` for Windows updates. +- Existing installations that still use the legacy `Jens/ForgeFlow` endpoint move to `ForgeFlow-Public` after installing this bridge release. An intentionally configured alternative update repository is preserved. +- A version change in the curated public source now starts the Windows quality, build, signing and release workflow. Source-only updates do not create another binary release. +- The publisher refuses to replace an already published version or attach assets built from a different commit. Release manifests continue to bind the executable checksums to the exact public source commit and the existing Ed25519 publisher key. + +The legacy repository remains available for older installations until the bridge update has been verified in the field. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fc9f4bc..7f84be7 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,8 @@ ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS. +The private source repository publishes a curated snapshot to `Jens/ForgeFlow-Public`. A version change on its `main` branch triggers the Windows release workflow. The workflow checks the public-source manifest, runs the quality and browser gates, builds both Windows artifacts, signs the release manifest with the existing publisher key, and publishes the release after all required assets are present. A source-only change does not create another binary release. The legacy `Jens/ForgeFlow` repository remains available for the 0.10.16 updater bridge. + ## Quality gate ```powershell diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 0b615b1..f27ca74 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -14,13 +14,13 @@ Update a source checkout through Git instead: 4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist; 5. run `npm run check` before starting ForgeFlow. -The in-app updater remains available for signed packaged Windows releases. +The in-app updater remains available for signed packaged Windows releases. Version 0.10.16 migrates the default endpoint from `Jens/ForgeFlow` to `Jens/ForgeFlow-Public` after a bridge release from the legacy repository. A separately configured update repository is preserved. ## Packaged Windows updates ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update. -`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets: +A version bump in the curated `ForgeFlow-Public` source starts the Windows quality, build, signing and release workflow. It publishes eight required assets: - `ForgeFlow-Setup--win-x64.exe` - `ForgeFlow-Setup--win-x64.exe.sha256` @@ -31,9 +31,9 @@ ForgeFlow uses authenticated Gitea release assets when running from the installe - `ForgeFlow--release-manifest.json` - `ForgeFlow--release-manifest.json.sig` -Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation. +The release workflow uses the same Ed25519 private key that signed the legacy releases, stored as a repository-scoped Gitea Actions secret. Only the public key is committed. `npm run signing:setup` remains available for a local recovery build; do not generate a replacement key for existing installations. Authenticode can still be added separately for Windows reputation. -Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update. +The reviewed source sync runs for every merge to the private canonical repository. Source-only changes do not produce another Windows release. `workflow_dispatch` can retry a failed binary publication for the current public commit. When the source was already pushed without a binary release, run the recovery publisher from Windows: @@ -64,6 +64,6 @@ Set-ExecutionPolicy -Scope Process Bypass .\Publish-ForgeFlow-Release.ps1 ``` -The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified. +The manual script is a recovery path for an extracted, reviewed `ForgeFlow-Public` source export. It installs dependencies, runs the quality gate, verifies the exact published source commit, builds the Windows artifacts and uploads the signed release evidence. Publication fails when the source commit, publisher signature or required assets cannot be verified. An already published version is immutable; use a new version for later source changes. Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete. diff --git a/package-lock.json b/package-lock.json index 0a116b5..f4abf47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.10.15", + "version": "0.10.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.10.15", + "version": "0.10.16", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index de02e36..4e15c12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.10.15", + "version": "0.10.16", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -124,6 +124,7 @@ "docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.14.md", "docs/RELEASE_NOTES_0.10.15.md", + "docs/RELEASE_NOTES_0.10.16.md", "docs/CURRENT_STATE.md", "docs/MUTATION_MODEL.md", "docs/RELEASING.md", diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs index ba556a6..0e444e7 100644 --- a/scripts/publish-binary-release.cjs +++ b/scripts/publish-binary-release.cjs @@ -5,6 +5,7 @@ const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { app, safeStorage } = require("electron"); const { normalizeBaseUrl } = require("../src/shared/validation.cjs"); +const { existingReleaseState } = require("../src/shared/release-policy.cjs"); const root = path.resolve(__dirname, ".."); const configuredUserData = @@ -88,7 +89,7 @@ app.whenReady().then(async () => { "Release repository owner", ); const repo = safeRepositoryPart( - process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", + process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow-Public", "Release repository name", ); const branch = safeRepositoryPart( @@ -143,17 +144,10 @@ 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 }), - }, - ); + if (existingReleaseState(release, version, commit) === "published") { + console.log(`PASS ForgeFlow ${version} is already published for ${commit.slice(0, 7)}`); + app.exit(0); + return; } const binaries = [ path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), @@ -172,10 +166,6 @@ app.whenReady().then(async () => { 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, diff --git a/scripts/verify.mjs b/scripts/verify.mjs index aa8e690..53775ac 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -97,6 +97,7 @@ const required = [ "docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.14.md", "docs/RELEASE_NOTES_0.10.15.md", + "docs/RELEASE_NOTES_0.10.16.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -141,9 +142,9 @@ for (const file of required) { const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); -if (packageJson.version !== "0.10.15") +if (packageJson.version !== "0.10.16") throw new Error( - `Expected package version 0.10.15, got ${packageJson.version}.`, + `Expected package version 0.10.16, got ${packageJson.version}.`, ); if (!publicExport) { const sourceManifest = await readFile( diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index 0e206b8..0cdd4d5 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -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; diff --git a/src/main/update-service.cjs b/src/main/update-service.cjs index a8b2b5f..692c39d 100644 --- a/src/main/update-service.cjs +++ b/src/main/update-service.cjs @@ -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, diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index dfb7ed0..dea52e7 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -162,7 +162,7 @@ ], updates: { owner: "Jens", - repo: "ForgeFlow", + repo: "ForgeFlow-Public", branch: "main", autoCheck: true, lastCheckedAt: null, diff --git a/src/renderer/views.js b/src/renderer/views.js index 0048068..4877f79 100644 --- a/src/renderer/views.js +++ b/src/renderer/views.js @@ -762,7 +762,7 @@ function renderSettings() { const servers = state.servers || []; return `

Gitea connection

${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}
${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}
-

ForgeFlow updates

Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}
${icon(update?.available ? "download" : "check")}${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)}`}${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}
${update?.available && update.packaged && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${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."}
+

ForgeFlow updates

Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow-Public")}
${icon(update?.available ? "download" : "check")}${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)}`}${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."}
${update?.available && update.packaged && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${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."}

SSH / Unraid servers

Credentials are encrypted locally; a new host fingerprint is shown before authentication.
${servers.length ? `
${servers.map((server) => `
${icon("server")}
${escapeHtml(server.name)}${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}
`).join("")}
` : '

No SSH server configured. Add your Unraid server before creating an SSH deployment profile.

'}

Git remote maintenance

Standardize linked repositories to the current Gitea SSH URLs.

This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.

Project roots

The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.

${state.workspaceRoots.map((root, index) => `
${index === 0 ? 'Default' : ""}
`).join("")}
diff --git a/src/shared/release-policy.cjs b/src/shared/release-policy.cjs new file mode 100644 index 0000000..6d44854 --- /dev/null +++ b/src/shared/release-policy.cjs @@ -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 }; diff --git a/tests/config-store.test.mjs b/tests/config-store.test.mjs index d12a493..1b242c7 100644 --- a/tests/config-store.test.mjs +++ b/tests/config-store.test.mjs @@ -48,6 +48,20 @@ test("load creates missing config and recovers malformed JSON", async (t) => { assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-"))); }); +test("legacy ForgeFlow update endpoint migrates to the signed public releases", async (t) => { + const { directory, store } = await storeFixture(t); + assert.equal(store.data.updates.repo, "ForgeFlow-Public"); + await writeFile(store.filePath, JSON.stringify({ + updates: { owner: "Jens", repo: "ForgeFlow", branch: "main", autoCheck: true }, + }), "utf8"); + await store.load(); + assert.equal(store.data.updates.repo, "ForgeFlow-Public"); + assert.equal(JSON.parse(await readFile(store.filePath, "utf8")).updates.repo, "ForgeFlow-Public"); + assert.equal(store.migrate({ updates: { owner: "Team", repo: "ForgeFlow" } }).updates.repo, "ForgeFlow"); + assert.equal(store.migrate({ updates: { owner: "Jens", repo: "CustomUpdates" } }).updates.repo, "CustomUpdates"); + assert.equal(store.migrate({ updates: { owner: "Jens", repo: "ForgeFlow", branch: "custom" } }).updates.repo, "ForgeFlow"); +}); + test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => { const { store } = await storeFixture(t); assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/); diff --git a/tests/release-policy.test.mjs b/tests/release-policy.test.mjs new file mode 100644 index 0000000..a495753 --- /dev/null +++ b/tests/release-policy.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import releasePolicy from '../src/shared/release-policy.cjs'; + +const { windowsReleaseAssetNames, existingReleaseState } = releasePolicy; +const commit = 'a'.repeat(40); +const version = '0.10.16'; + +test('a draft for another commit cannot receive replacement release assets', () => { + assert.throws(() => existingReleaseState({ target_commitish: 'b'.repeat(40), draft: true }, version, commit), /Bump the version/); +}); + +test('a matching draft can resume while a complete published release is immutable', () => { + assert.equal(existingReleaseState({ target_commitish: commit, draft: true }, version, commit), 'draft'); + assert.equal(existingReleaseState({ target_commitish: commit, draft: false, assets: windowsReleaseAssetNames(version).map((name) => ({ name })) }, version, commit), 'published'); +}); + +test('a published release missing its signature cannot be treated as complete', () => { + const assets = windowsReleaseAssetNames(version).filter((name) => !name.endsWith('.sig')).map((name) => ({ name })); + assert.throws(() => existingReleaseState({ target_commitish: commit, draft: false, assets }, version, commit), /release-manifest.json.sig/); +}); diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index 7ea358e..d893f29 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -118,6 +118,33 @@ test("update repository parts reject path injection", async () => { await rm(temp, { recursive: true, force: true }); }); +test("packaged updates pin the published release commit despite later source-only changes", async (t) => { + const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-release-check-")); + t.after(() => rm(temp, { recursive: true, force: true })); + const releaseCommit = "b".repeat(40); + const service = new UpdateService({ + store: { data: { updates: {} }, save: async () => {} }, + gitea: { + async getLatestRelease(owner, repo) { + assert.equal(repo, "ForgeFlow-Public"); + return { tag_name: "v0.10.16", target_commitish: releaseCommit, draft: false, prerelease: false }; + }, + async getBranch() { throw new Error("Packaged updates must not follow mutable main."); }, + async getRepositoryFile(input) { + assert.equal(input.ref, releaseCommit); + return { decoded: JSON.stringify({ name: "forgeflow", version: "0.10.16" }) }; + }, + }, + appInfo: { version: "0.10.14", packaged: true }, + sourcePath: temp, + userDataPath: temp, + }); + const result = await service.check(); + assert.equal(result.available, true); + assert.equal(result.remoteSha, releaseCommit); + assert.equal(result.releaseTag, "v0.10.16"); +}); + test("source updater refuses an unsigned archive before launching a helper", async () => { const temp = await mkdtemp( path.join(os.tmpdir(), "forgeflow-update-handshake-"), @@ -734,11 +761,13 @@ test("Windows release pipeline emits signed provenance, manifest and SBOM eviden assert.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/); assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/); assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/); - assert.ok( - releaseWorkflow.indexOf("Validate version bump and build release artifacts") < - releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"), - "signing secrets must not be present during dependency installation and quality checks", - ); + const buildStart = releaseWorkflow.indexOf("- name: Build and validate release artifacts"); + const signingStart = releaseWorkflow.indexOf("- name: Sign and publish validated artifacts"); + assert.ok(buildStart >= 0 && signingStart > buildStart); + assert.doesNotMatch(releaseWorkflow.slice(buildStart, signingStart), /FORGEFLOW_RELEASE_SIGNING_KEY_PEM/); + assert.match(releaseWorkflow, /push:\s*\n\s*branches: \[main\]/); + assert.match(releaseWorkflow, /should_release=false/); + assert.match(releaseWorkflow, /should_release=true/); assert.match(releaseWorkflow, /finally \{/); assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/); const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");