Publish curated ForgeFlow source from 63022ccf9a37242d573297c8ce7f15db61acc34e
This commit is contained in:
1 parent
bdfcc1585d
commit
04cbfccbdb
19 files changed
+233
-77
No files matched your search
@@ -1,16 +1,23 @@
|
|||||||
name: ForgeFlow signed release
|
name: ForgeFlow signed release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
code: read
|
code: read
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: forgeflow-signed-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
if: ${{ gitea.repository == 'Jens/ForgeFlow-Public' }}
|
if: ${{ gitea.repository == 'Jens/ForgeFlow-Public' }}
|
||||||
runs-on: windows-native
|
runs-on: windows-native
|
||||||
|
timeout-minutes: 90
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
@@ -19,7 +26,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- name: Validate version bump and build release artifacts
|
- name: Select versioned release
|
||||||
|
id: preflight
|
||||||
shell: powershell
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
GITEA_EVENT_NAME: ${{ gitea.event_name }}
|
GITEA_EVENT_NAME: ${{ gitea.event_name }}
|
||||||
@@ -40,12 +48,24 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($env:GITEA_EVENT_NAME -eq "push" -and $previousVersion -eq $version) {
|
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
|
exit 0
|
||||||
}
|
}
|
||||||
if ($version -notmatch '^\d+\.\d+\.\d+$') {
|
if ($version -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
throw "ForgeFlow version '$version' is not a stable semantic version."
|
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"
|
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
|
||||||
if ($LASTEXITCODE -ne 0) { throw "npm ci failed." }
|
if ($LASTEXITCODE -ne 0) { throw "npm ci failed." }
|
||||||
@@ -65,6 +85,7 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." }
|
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." }
|
||||||
|
|
||||||
- name: Sign and publish validated artifacts
|
- name: Sign and publish validated artifacts
|
||||||
|
if: ${{ steps.preflight.outputs.should_release == 'true' }}
|
||||||
shell: powershell
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Public source export
|
# 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.
|
||||||
+43
-28
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "db8c79fe5c6c08f4971437d5893c390fd4e45de5",
|
"sourceRevision": "63022ccf9a37242d573297c8ce7f15db61acc34e",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".gitattributes",
|
"path": ".gitattributes",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": ".gitea/workflows/release.yml",
|
"path": ".gitea/workflows/release.yml",
|
||||||
"sha256": "51ce6b5cc8c8cc5b871dcdecca28664e2b8c9f948b6b989389cc22d9b5f943c1",
|
"sha256": "8d0f2a6606d7494d2a3bf293d09925380f5c6a2340e418989c5fb52815da2032",
|
||||||
"bytes": 4854
|
"bytes": 5651
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": ".gitignore",
|
"path": ".gitignore",
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "README.md",
|
"path": "README.md",
|
||||||
"sha256": "56beb22de0ddda207d5bdcb906c549f6fb0ddc47795ebc653fafa5bb8bf6b8d4",
|
"sha256": "cf1559c01c61e69d98d2de9554f042d7e940245bfbb7dc58924576c1009b0aab",
|
||||||
"bytes": 5549
|
"bytes": 5699
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "SECURITY.md",
|
"path": "SECURITY.md",
|
||||||
@@ -242,6 +242,11 @@
|
|||||||
"sha256": "18f34f2fd3650e98f7d2fd63e787894a294585a7e93fbf6b877663ee9d66ec51",
|
"sha256": "18f34f2fd3650e98f7d2fd63e787894a294585a7e93fbf6b877663ee9d66ec51",
|
||||||
"bytes": 1856
|
"bytes": 1856
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/RELEASE_NOTES_0.10.16.md",
|
||||||
|
"sha256": "56c93642ed59a30dc3433f1aec54150423241f59cf8ddcab2bf7e9ce4e91b477",
|
||||||
|
"bytes": 1051
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "docs/RELEASE_NOTES_0.10.2.md",
|
"path": "docs/RELEASE_NOTES_0.10.2.md",
|
||||||
"sha256": "8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c",
|
"sha256": "8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c",
|
||||||
@@ -454,8 +459,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docs/RELEASING.md",
|
"path": "docs/RELEASING.md",
|
||||||
"sha256": "1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3",
|
"sha256": "f05d671c8b81405583a234bd449c2333c6abb486f17002da80b34dbed113ba8a",
|
||||||
"bytes": 2242
|
"bytes": 2797
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docs/ROADMAP.md",
|
"path": "docs/ROADMAP.md",
|
||||||
@@ -494,8 +499,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docs/UPDATING.md",
|
"path": "docs/UPDATING.md",
|
||||||
"sha256": "485828f99531e188629d0f25dc2905b9ef99a9f08ccbb6ab051530398266c01c",
|
"sha256": "40ac4dff2cebda1d9a552ac89434ac95bf28c46c2a980afc828dc3198e26c50e",
|
||||||
"bytes": 4443
|
"bytes": 4744
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "docs/screenshots/deploy-confirmation.png",
|
"path": "docs/screenshots/deploy-confirmation.png",
|
||||||
@@ -584,13 +589,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "package-lock.json",
|
"path": "package-lock.json",
|
||||||
"sha256": "163c276240d548fd885b68b85475b92de4431b1313da89edbdd811a32452847c",
|
"sha256": "5fdb7dea5720d66ef63c02151b0a69ce1e02fb3e1d7c54743fdf7a1b45e5c44c",
|
||||||
"bytes": 179808
|
"bytes": 179808
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "package.json",
|
"path": "package.json",
|
||||||
"sha256": "d2c1e8b55fe0a89145b64c0eccd50b830e99324d99178f448ef0f0a57066bae1",
|
"sha256": "84f1c67703c8ddb7af560393f9c4cefa8026a10283567912e44c32686ace31fa",
|
||||||
"bytes": 6330
|
"bytes": 6369
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "playwright.config.mjs",
|
"path": "playwright.config.mjs",
|
||||||
@@ -644,8 +649,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/publish-binary-release.cjs",
|
"path": "scripts/publish-binary-release.cjs",
|
||||||
"sha256": "19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168",
|
"sha256": "1bd68c9c6155d00d87d57e1afd7198940e0496fb585f84b3037aee923b3857c9",
|
||||||
"bytes": 9358
|
"bytes": 9136
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/serve-demo.mjs",
|
"path": "scripts/serve-demo.mjs",
|
||||||
@@ -679,8 +684,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/verify.mjs",
|
"path": "scripts/verify.mjs",
|
||||||
"sha256": "41581e5c70e079775125e62509312f2c334b960590fc5bad4be71931c6126fcd",
|
"sha256": "c7cc83b222315ad5f651c7e5c3a25058a15debc8fb1e2b0a8f2d8de0f768d920",
|
||||||
"bytes": 22915
|
"bytes": 22950
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/write-release-checksums.mjs",
|
"path": "scripts/write-release-checksums.mjs",
|
||||||
@@ -699,8 +704,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/main/config-store.cjs",
|
"path": "src/main/config-store.cjs",
|
||||||
"sha256": "e3af59fafa497d032541979bea5fe2b98187b1ced619c567d15ae79ee0904c1f",
|
"sha256": "5363a32151109a8b2763b1d42a4aae60130906af042791f5700ecd3e49013765",
|
||||||
"bytes": 34629
|
"bytes": 35003
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/main/configuration-backup.cjs",
|
"path": "src/main/configuration-backup.cjs",
|
||||||
@@ -874,8 +879,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/main/update-service.cjs",
|
"path": "src/main/update-service.cjs",
|
||||||
"sha256": "29b8c5eca83b0e89c7d0716945b5316aac43947b5387e89562d9a024ebc4663c",
|
"sha256": "e95c33e4cda06e75e10baea8bf47107cfcd3c6e36c576afa65a94bd54c247dc3",
|
||||||
"bytes": 27204
|
"bytes": 27908
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/renderer/actions/command.js",
|
"path": "src/renderer/actions/command.js",
|
||||||
@@ -959,8 +964,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/renderer/mock-bridge.js",
|
"path": "src/renderer/mock-bridge.js",
|
||||||
"sha256": "98f4d136c56b4a4e681f6ed77c34e18be69f0f87074520c1be8210f3729d59bb",
|
"sha256": "eff6ea1443ea0a8c19151d715b8e96e1d2b688d32f5906d69590c71809525bf8",
|
||||||
"bytes": 21355
|
"bytes": 21362
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/renderer/mock-deployment-bridge.js",
|
"path": "src/renderer/mock-deployment-bridge.js",
|
||||||
@@ -984,8 +989,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/renderer/views.js",
|
"path": "src/renderer/views.js",
|
||||||
"sha256": "51b6499c4cbee2d25baa86cc6f0966646dce31319ebb5846c4006d2624ee7610",
|
"sha256": "b5dacd043c1e8f06f9ab86f652bb94523f5e1ac9e3873e0947c14ae7531251ea",
|
||||||
"bytes": 112935
|
"bytes": 113019
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/clone-target.cjs",
|
"path": "src/shared/clone-target.cjs",
|
||||||
@@ -1002,6 +1007,11 @@
|
|||||||
"sha256": "029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f",
|
"sha256": "029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f",
|
||||||
"bytes": 3057
|
"bytes": 3057
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "src/shared/release-policy.cjs",
|
||||||
|
"sha256": "59b4c20d3cfdecdeb8f2126b5e525f2ae99b26a53a3914a2a7a63bbc1cf5643d",
|
||||||
|
"bytes": 1099
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "src/shared/repository-match.cjs",
|
"path": "src/shared/repository-match.cjs",
|
||||||
"sha256": "2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75",
|
"sha256": "2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75",
|
||||||
@@ -1064,8 +1074,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/config-store.test.mjs",
|
"path": "tests/config-store.test.mjs",
|
||||||
"sha256": "8aa8789a984769b377f719abca42a428fffc4f89b3997d57184a8073d24121e2",
|
"sha256": "3bdd45107f0bdba18d608932aabea56ca11224e41578f2de2c93b1805175f16a",
|
||||||
"bytes": 18325
|
"bytes": 19224
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/configuration-backup.test.mjs",
|
"path": "tests/configuration-backup.test.mjs",
|
||||||
@@ -1177,6 +1187,11 @@
|
|||||||
"sha256": "0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3",
|
"sha256": "0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3",
|
||||||
"bytes": 6435
|
"bytes": 6435
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/release-policy.test.mjs",
|
||||||
|
"sha256": "6f26ae12a39a4711cf699f1a68e8f673e1e53e2cc34f5a6f362a66ae15ba9d1a",
|
||||||
|
"bytes": 1229
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/renderer-workflow.test.mjs",
|
"path": "tests/renderer-workflow.test.mjs",
|
||||||
"sha256": "1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e",
|
"sha256": "1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e",
|
||||||
@@ -1244,8 +1259,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/update-service.test.mjs",
|
"path": "tests/update-service.test.mjs",
|
||||||
"sha256": "2357ce799b38edae940f6bb5f047d0b9d013d88ce47a9bed0416a41e6af3d5f8",
|
"sha256": "a0a8b8ae8c13e74f079279f7f36f255c58995b6c95fdbec687d38befeb7dcd1f",
|
||||||
"bytes": 30648
|
"bytes": 32106
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/validation.test.mjs",
|
"path": "tests/validation.test.mjs",
|
||||||
|
|||||||
@@ -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.
|
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
|
## See the workflow
|
||||||
|
|
||||||
@@ -37,12 +37,12 @@ ForgeFlow distinguishes a matching commit from a healthy deployment. It keeps in
|
|||||||
|
|
||||||
### Install the Windows app
|
### 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.
|
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.
|
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.
|
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
|
### 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.
|
`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).
|
ForgeFlow is available under the [MIT License](LICENSE). Report security issues through [SECURITY.md](SECURITY.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.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS.
|
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
|
## Quality gate
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
+5
-5
@@ -14,13 +14,13 @@ Update a source checkout through Git instead:
|
|||||||
4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist;
|
4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist;
|
||||||
5. run `npm run check` before starting ForgeFlow.
|
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
|
## 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.
|
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-<version>-win-x64.exe`
|
- `ForgeFlow-Setup-<version>-win-x64.exe`
|
||||||
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
||||||
@@ -31,9 +31,9 @@ ForgeFlow uses authenticated Gitea release assets when running from the installe
|
|||||||
- `ForgeFlow-<version>-release-manifest.json`
|
- `ForgeFlow-<version>-release-manifest.json`
|
||||||
- `ForgeFlow-<version>-release-manifest.json.sig`
|
- `ForgeFlow-<version>-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:
|
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
|
.\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.
|
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.15",
|
"version": "0.10.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.15",
|
"version": "0.10.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.15",
|
"version": "0.10.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||||
"main": "main.cjs",
|
"main": "main.cjs",
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
"docs/RELEASE_NOTES_0.10.13.md",
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/RELEASE_NOTES_0.10.14.md",
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
"docs/RELEASE_NOTES_0.10.15.md",
|
"docs/RELEASE_NOTES_0.10.15.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.16.md",
|
||||||
"docs/CURRENT_STATE.md",
|
"docs/CURRENT_STATE.md",
|
||||||
"docs/MUTATION_MODEL.md",
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/RELEASING.md",
|
"docs/RELEASING.md",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const path = require("node:path");
|
|||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync } = require("node:child_process");
|
||||||
const { app, safeStorage } = require("electron");
|
const { app, safeStorage } = require("electron");
|
||||||
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
|
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
|
||||||
|
const { existingReleaseState } = require("../src/shared/release-policy.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
const configuredUserData =
|
const configuredUserData =
|
||||||
@@ -88,7 +89,7 @@ app.whenReady().then(async () => {
|
|||||||
"Release repository owner",
|
"Release repository owner",
|
||||||
);
|
);
|
||||||
const repo = safeRepositoryPart(
|
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",
|
"Release repository name",
|
||||||
);
|
);
|
||||||
const branch = safeRepositoryPart(
|
const branch = safeRepositoryPart(
|
||||||
@@ -143,17 +144,10 @@ app.whenReady().then(async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (release.draft !== true) {
|
if (existingReleaseState(release, version, commit) === "published") {
|
||||||
release = await api(
|
console.log(`PASS ForgeFlow ${version} is already published for ${commit.slice(0, 7)}`);
|
||||||
baseUrl,
|
app.exit(0);
|
||||||
token,
|
return;
|
||||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ draft: true }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const binaries = [
|
const binaries = [
|
||||||
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
|
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
|
||||||
@@ -172,10 +166,6 @@ app.whenReady().then(async () => {
|
|||||||
const existing = (release.assets || []).find(
|
const existing = (release.assets || []).find(
|
||||||
(asset) => asset.name === name,
|
(asset) => asset.name === name,
|
||||||
);
|
);
|
||||||
if (existing && Number(existing.size) === bytes.length) {
|
|
||||||
console.log(`SKIP ${name} already published`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await api(
|
await api(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|||||||
+3
-2
@@ -97,6 +97,7 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.10.13.md",
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/RELEASE_NOTES_0.10.14.md",
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
"docs/RELEASE_NOTES_0.10.15.md",
|
"docs/RELEASE_NOTES_0.10.15.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.16.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -141,9 +142,9 @@ for (const file of required) {
|
|||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
if (packageJson.version !== "0.10.15")
|
if (packageJson.version !== "0.10.16")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.10.15, got ${packageJson.version}.`,
|
`Expected package version 0.10.16, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
if (!publicExport) {
|
if (!publicExport) {
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const DEFAULT_CONFIG = {
|
|||||||
favorites: [],
|
favorites: [],
|
||||||
updates: {
|
updates: {
|
||||||
owner: 'Jens',
|
owner: 'Jens',
|
||||||
repo: 'ForgeFlow',
|
repo: 'ForgeFlow-Public',
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
autoCheck: true,
|
autoCheck: true,
|
||||||
lastCheckedAt: null
|
lastCheckedAt: null
|
||||||
@@ -124,7 +124,15 @@ class ConfigStore {
|
|||||||
: {},
|
: {},
|
||||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||||
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
|
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') : [],
|
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
|
||||||
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
|
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
|
||||||
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
|
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) {
|
async setUpdatePreferences(updates) {
|
||||||
const next = { ...this.data.updates, ...(updates || {}) };
|
const next = { ...this.data.updates, ...(updates || {}) };
|
||||||
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
|
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.branch = assertBranchName(next.branch || 'main');
|
||||||
next.autoCheck = next.autoCheck !== false;
|
next.autoCheck = next.autoCheck !== false;
|
||||||
this.data.updates = next;
|
this.data.updates = next;
|
||||||
|
|||||||
@@ -265,16 +265,28 @@ class UpdateService {
|
|||||||
"Update repository owner",
|
"Update repository owner",
|
||||||
);
|
);
|
||||||
const repo = safeRepositoryPart(
|
const repo = safeRepositoryPart(
|
||||||
settings.repo || "ForgeFlow",
|
settings.repo || "ForgeFlow-Public",
|
||||||
"Update repository name",
|
"Update repository name",
|
||||||
);
|
);
|
||||||
const branchName = String(settings.branch || "main").trim();
|
const branchName = String(settings.branch || "main").trim();
|
||||||
const branch = await this.gitea.getBranch(owner, repo, branchName);
|
let remoteSha;
|
||||||
const remoteSha =
|
let releaseTag = null;
|
||||||
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
|
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 || "")))
|
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
|
||||||
throw new Error(
|
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({
|
const file = await this.gitea.getRepositoryFile({
|
||||||
@@ -294,6 +306,9 @@ class UpdateService {
|
|||||||
"The configured update repository is not a ForgeFlow source repository.",
|
"The configured update repository is not a ForgeFlow source repository.",
|
||||||
);
|
);
|
||||||
const remoteVersion = String(manifest.version || "").trim();
|
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 currentVersion = String(this.appInfo.version || "").trim();
|
||||||
const available = isNewerVersion(remoteVersion, currentVersion);
|
const available = isNewerVersion(remoteVersion, currentVersion);
|
||||||
const result = {
|
const result = {
|
||||||
@@ -301,6 +316,7 @@ class UpdateService {
|
|||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
|
releaseTag,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
remoteVersion,
|
remoteVersion,
|
||||||
remoteSha,
|
remoteSha,
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
],
|
],
|
||||||
updates: {
|
updates: {
|
||||||
owner: "Jens",
|
owner: "Jens",
|
||||||
repo: "ForgeFlow",
|
repo: "ForgeFlow-Public",
|
||||||
branch: "main",
|
branch: "main",
|
||||||
autoCheck: true,
|
autoCheck: true,
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: null,
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ function renderSettings() {
|
|||||||
const servers = state.servers || [];
|
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>
|
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"><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>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"><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>
|
<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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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-")));
|
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) => {
|
test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => {
|
||||||
const { store } = await storeFixture(t);
|
const { store } = await storeFixture(t);
|
||||||
assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/);
|
assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/);
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -118,6 +118,33 @@ test("update repository parts reject path injection", async () => {
|
|||||||
await rm(temp, { recursive: true, force: true });
|
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 () => {
|
test("source updater refuses an unsigned archive before launching a helper", async () => {
|
||||||
const temp = await mkdtemp(
|
const temp = await mkdtemp(
|
||||||
path.join(os.tmpdir(), "forgeflow-update-handshake-"),
|
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.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/);
|
||||||
assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/);
|
assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/);
|
||||||
assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/);
|
assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/);
|
||||||
assert.ok(
|
const buildStart = releaseWorkflow.indexOf("- name: Build and validate release artifacts");
|
||||||
releaseWorkflow.indexOf("Validate version bump and build release artifacts") <
|
const signingStart = releaseWorkflow.indexOf("- name: Sign and publish validated artifacts");
|
||||||
releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"),
|
assert.ok(buildStart >= 0 && signingStart > buildStart);
|
||||||
"signing secrets must not be present during dependency installation and quality checks",
|
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, /finally \{/);
|
||||||
assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/);
|
assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/);
|
||||||
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
||||||
|
|||||||
Reference in new issue
Block a user