diff --git a/.gitea/workflows/custom-release.yml b/.gitea/workflows/custom-release.yml
new file mode 100644
index 000000000..ae5e346d6
--- /dev/null
+++ b/.gitea/workflows/custom-release.yml
@@ -0,0 +1,142 @@
+name: custom release
+
+permissions:
+ contents: write
+ releases: write
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - ".gitea/workflows/custom-release.yml"
+ - "patches/**"
+ - "scripts/update-custom-release.sh"
+ - "scripts/sync-upstream.sh"
+ workflow_dispatch:
+ inputs:
+ upstream_tag:
+ description: "Optional upstream Syncthing tag, for example v2.1.0"
+ required: false
+ suffix:
+ description: "Optional custom release suffix, for example stignore.7"
+ required: false
+ schedule:
+ - cron: "17 04 * * *"
+
+jobs:
+ build-custom-release:
+ runs-on: ffmini_macos_arm64
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: false
+
+ - name: Configure Git author
+ run: |
+ git config user.name "Gitea Actions"
+ git config user.email "actions@git.felixfoertsch.de"
+
+ - name: Mirror upstream and rebuild patched main
+ run: ./scripts/sync-upstream.sh
+ env:
+ SYNC_REMOTE: origin
+
+ - name: Set up tea
+ run: |
+ go install code.gitea.io/tea@v0.14.1
+ echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
+ "$(go env GOPATH)/bin/tea" logins delete actions >/dev/null 2>&1 || true
+ "$(go env GOPATH)/bin/tea" logins add --name actions --url https://git.felixfoertsch.de --token "$GITEA_TOKEN" --no-version-check
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+
+ - name: Import Developer ID certificate
+ run: |
+ set -euo pipefail
+ keychain_dir="$HOME/Library/Keychains"
+ mkdir -p "$keychain_dir"
+ keychain_path="$keychain_dir/syncthing-release-signing-${GITHUB_RUN_ID:-$$}.keychain-db"
+ keychain_password="$(openssl rand -hex 24)"
+ certificate_path="$RUNNER_TEMP/developer-id-application.p12"
+ previous_default_keychain="$(security default-keychain -d user 2>/dev/null | sed 's/[ "]//g' || true)"
+
+ echo "CUSTOM_RELEASE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_KEYCHAIN_PASSWORD=$keychain_password" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN=$previous_default_keychain" >> "$GITHUB_ENV"
+
+ if [ -z "$DEVELOPER_ID_APPLICATION_P12_BASE64" ]; then
+ echo "DEVELOPER_ID_APPLICATION_P12_BASE64 secret is required" >&2
+ exit 1
+ fi
+
+ printf '%s' "$DEVELOPER_ID_APPLICATION_P12_BASE64" | base64 -D > "$certificate_path"
+ rm -f "$keychain_path"
+ security create-keychain -p "$keychain_password" "$keychain_path"
+ security set-keychain-settings -lut 21600 "$keychain_path"
+ security unlock-keychain -p "$keychain_password" "$keychain_path"
+ security import "$certificate_path" -k "$keychain_path" -P "$DEVELOPER_ID_APPLICATION_P12_PASSWORD" -A -T /usr/bin/codesign -T /usr/bin/security
+ existing_keychains=()
+ while IFS= read -r existing_keychain; do
+ existing_keychain="$(printf '%s' "$existing_keychain" | sed 's/[ "]//g')"
+ if [ -n "$existing_keychain" ] && [ -e "$existing_keychain" ] && [[ "$existing_keychain" != *"/syncthing-release-signing-"*".keychain-db" ]]; then
+ existing_keychains+=("$existing_keychain")
+ fi
+ done < <(security list-keychains)
+ security list-keychains -s "$keychain_path" "${existing_keychains[@]}"
+ security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}" || true
+ security default-keychain -d user -s "$keychain_path" || true
+ security list-keychains
+ security list-keychains -d user || true
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path"
+ identity_output="$(security find-identity -v -p codesigning "$keychain_path")"
+ printf '%s\n' "$identity_output"
+ security find-identity -v -p codesigning
+ codesign_identity_sha1="$(printf '%s\n' "$identity_output" | awk '/"Developer ID Application:/ { print $2; exit }')"
+ codesign_identity="$(printf '%s\n' "$identity_output" | sed -n 's/.*"\(Developer ID Application:[^"]*\)".*/\1/p' | head -n 1)"
+ if [ -z "$codesign_identity" ]; then
+ echo "Developer ID Application signing identity is required in DEVELOPER_ID_APPLICATION_P12_BASE64" >&2
+ exit 1
+ fi
+
+ probe_binary="$RUNNER_TEMP/codesign-probe"
+ cp /usr/bin/true "$probe_binary"
+ codesign --force --dryrun --sign "$codesign_identity" --keychain "$keychain_path" --options runtime --timestamp "$probe_binary"
+
+ echo "CUSTOM_RELEASE_CODESIGN_IDENTITY=$codesign_identity" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_CODESIGN_IDENTITY_SHA1=$codesign_identity_sha1" >> "$GITHUB_ENV"
+ env:
+ DEVELOPER_ID_APPLICATION_P12_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64 }}
+ DEVELOPER_ID_APPLICATION_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
+
+ - name: Build patched Syncthing release
+ run: ./scripts/update-custom-release.sh
+ env:
+ CUSTOM_RELEASE_UPSTREAM_TAG: ${{ github.event.inputs.upstream_tag }}
+ CUSTOM_RELEASE_SUFFIX: ${{ github.event.inputs.suffix }}
+ CUSTOM_RELEASE_PUSH: "1"
+ CUSTOM_RELEASE_PUSH_BRANCH: "0"
+ CUSTOM_RELEASE_REMOTE: origin
+ CUSTOM_RELEASE_BUILDS: "darwin/arm64/zip/1 linux/amd64/tar/0 linux/arm64/tar/0"
+ CUSTOM_RELEASE_CODESIGN_TEAM_ID: "NG5W75WE8U"
+ CUSTOM_RELEASE_SIGN_DARWIN: "1"
+ CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT: "0"
+ CUSTOM_RELEASE_CREATE_GITEA_RELEASE: "1"
+ CUSTOM_RELEASE_TEA_REPO: felixfoertsch/syncthing
+
+ - name: Delete temporary keychain
+ if: always()
+ run: |
+ if [ -n "${CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN:-}" ] && [ -e "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" ]; then
+ security default-keychain -d user -s "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" || true
+ fi
+ if [ -n "${CUSTOM_RELEASE_KEYCHAIN_PATH:-}" ]; then
+ security delete-keychain "$CUSTOM_RELEASE_KEYCHAIN_PATH" || true
+ fi
diff --git a/.github/workflows/custom-release.yml b/.github/workflows/custom-release.yml
new file mode 100644
index 000000000..e9e3f7208
--- /dev/null
+++ b/.github/workflows/custom-release.yml
@@ -0,0 +1,150 @@
+name: custom release
+
+permissions:
+ contents: write
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/custom-release.yml"
+ - "patches/**"
+ - "scripts/update-custom-release.sh"
+ - "scripts/sync-upstream.sh"
+ workflow_dispatch:
+ inputs:
+ upstream_tag:
+ description: "Optional upstream Syncthing tag, for example v2.1.3"
+ required: false
+ suffix:
+ description: "Optional custom release suffix, for example stignore.7"
+ required: false
+ schedule:
+ - cron: "17 04 * * *"
+
+jobs:
+ build-custom-release:
+ runs-on: macos-14
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: false
+
+ - name: Configure Git author
+ run: |
+ git config user.name "GitHub Actions"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Mirror upstream and rebuild patched main
+ run: ./scripts/sync-upstream.sh
+ env:
+ SYNC_REMOTE: origin
+
+ - name: Import Developer ID certificate
+ run: |
+ set -euo pipefail
+ keychain_path="$RUNNER_TEMP/syncthing-release-signing.keychain-db"
+ keychain_password="$(openssl rand -hex 24)"
+ certificate_path="$RUNNER_TEMP/developer-id-application.p12"
+ previous_default_keychain="$(security default-keychain -d user 2>/dev/null | sed 's/[ "]//g' || true)"
+
+ echo "::add-mask::$keychain_password"
+ echo "CUSTOM_RELEASE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_KEYCHAIN_PASSWORD=$keychain_password" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"
+ echo "CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN=$previous_default_keychain" >> "$GITHUB_ENV"
+
+ if [ -z "$DEVELOPER_ID_APPLICATION_P12_BASE64" ]; then
+ echo "DEVELOPER_ID_APPLICATION_P12_BASE64 secret is required" >&2
+ exit 1
+ fi
+ if [ -z "$DEVELOPER_ID_APPLICATION_P12_PASSWORD" ]; then
+ echo "DEVELOPER_ID_APPLICATION_P12_PASSWORD secret is required" >&2
+ exit 1
+ fi
+
+ printf '%s' "$DEVELOPER_ID_APPLICATION_P12_BASE64" | base64 -D > "$certificate_path"
+ security create-keychain -p "$keychain_password" "$keychain_path"
+ security set-keychain-settings -lut 21600 "$keychain_path"
+ security unlock-keychain -p "$keychain_password" "$keychain_path"
+ security import "$certificate_path" -k "$keychain_path" -P "$DEVELOPER_ID_APPLICATION_P12_PASSWORD" -A -T /usr/bin/codesign -T /usr/bin/security
+ security list-keychains -d user -s "$keychain_path"
+ security default-keychain -d user -s "$keychain_path"
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path"
+
+ identity_output="$(security find-identity -v -p codesigning "$keychain_path")"
+ printf '%s\n' "$identity_output"
+ codesign_identity="$(printf '%s\n' "$identity_output" | sed -n 's/.*"\(Developer ID Application:[^"]*\)".*/\1/p' | head -n 1)"
+ if [ -z "$codesign_identity" ]; then
+ echo "Developer ID Application signing identity is required in DEVELOPER_ID_APPLICATION_P12_BASE64" >&2
+ exit 1
+ fi
+
+ probe_binary="$RUNNER_TEMP/codesign-probe"
+ cp /usr/bin/true "$probe_binary"
+ codesign --force --dryrun --sign "$codesign_identity" --keychain "$keychain_path" --options runtime --timestamp "$probe_binary"
+ echo "CUSTOM_RELEASE_CODESIGN_IDENTITY=$codesign_identity" >> "$GITHUB_ENV"
+ env:
+ DEVELOPER_ID_APPLICATION_P12_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64 }}
+ DEVELOPER_ID_APPLICATION_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
+
+ - name: Build patched Syncthing release
+ run: |
+ case "$(uname -m)" in
+ arm64) darwin_arch=arm64 ;;
+ x86_64) darwin_arch=amd64 ;;
+ *) echo "Unsupported macOS runner architecture: $(uname -m)" >&2; exit 1 ;;
+ esac
+ export CUSTOM_RELEASE_BUILDS="darwin/$darwin_arch/zip/1 linux/amd64/tar/0 linux/arm64/tar/0"
+ ./scripts/update-custom-release.sh
+ env:
+ CUSTOM_RELEASE_UPSTREAM_TAG: ${{ github.event.inputs.upstream_tag }}
+ CUSTOM_RELEASE_SUFFIX: ${{ github.event.inputs.suffix }}
+ CUSTOM_RELEASE_PUSH: "1"
+ CUSTOM_RELEASE_PUSH_BRANCH: "0"
+ CUSTOM_RELEASE_REMOTE: origin
+ CUSTOM_RELEASE_CODESIGN_TEAM_ID: "NG5W75WE8U"
+ CUSTOM_RELEASE_SIGN_DARWIN: "1"
+ CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT: "0"
+ CUSTOM_RELEASE_CREATE_GITEA_RELEASE: "0"
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Publish GitHub release
+ run: |
+ tag="${CUSTOM_RELEASE_UPSTREAM_TAG:-$(git ls-remote --refs --tags --sort='version:refname' https://github.com/syncthing/syncthing.git 'v[0-9]*' | awk '{ tag = $2; sub("refs/tags/", "", tag); if (tag ~ /^v[0-9]+\.[0-9]+\.[0-9]+$/) latest = tag } END { print latest }')}-$CUSTOM_RELEASE_SUFFIX"
+ if gh release view "$tag" >/dev/null 2>&1; then
+ echo "GitHub release $tag already exists; nothing to do."
+ exit 0
+ fi
+ assets=()
+ for asset in dist/*; do
+ [ -f "$asset" ] || continue
+ [ "$(basename "$asset")" = release-notes.md ] && continue
+ assets+=("$asset")
+ done
+ gh release create "$tag" "${assets[@]}" --title "$tag" --notes-file dist/release-notes.md --verify-tag
+ env:
+ CUSTOM_RELEASE_UPSTREAM_TAG: ${{ github.event.inputs.upstream_tag }}
+ CUSTOM_RELEASE_SUFFIX: ${{ github.event.inputs.suffix || 'stignore.7' }}
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Delete temporary keychain
+ if: always()
+ run: |
+ if [ -n "${CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN:-}" ] && [ -e "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" ]; then
+ security default-keychain -d user -s "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" || true
+ fi
+ if [ -n "${CUSTOM_RELEASE_KEYCHAIN_PATH:-}" ]; then
+ security delete-keychain "$CUSTOM_RELEASE_KEYCHAIN_PATH" || true
+ fi
+ if [ -n "${CUSTOM_RELEASE_CERTIFICATE_PATH:-}" ]; then
+ rm -f "$CUSTOM_RELEASE_CERTIFICATE_PATH"
+ fi
diff --git a/README.md b/README.md
index 3167b1b4d..85d4f7ad5 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,11 @@
+# Syncthing with `.stignore` synchronization
+
+This fork synchronizes the root-level `.stignore` file as regular folder
+content, while keeping `.stfolder` and `.stversions` protected as Syncthing
+internals. The `upstream` branch mirrors the official Syncthing `main` branch;
+this fork's `main` branch and releases apply the `.stignore` synchronization
+patch.
+
[![Syncthing][14]][15]
---
diff --git a/gui/default/index.html b/gui/default/index.html
index 4bca219db..8d5469fc6 100644
--- a/gui/default/index.html
+++ b/gui/default/index.html
@@ -1017,6 +1017,9 @@
+
diff --git a/lib/api/auto/custom_marker_test.go b/lib/api/auto/custom_marker_test.go
new file mode 100644
index 000000000..71c382e50
--- /dev/null
+++ b/lib/api/auto/custom_marker_test.go
@@ -0,0 +1,42 @@
+// Copyright (C) 2026 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package auto
+
+import (
+ "compress/gzip"
+ "io"
+ "strings"
+ "testing"
+)
+
+const customBuildMarker = "It syncs .stignore now!"
+
+func TestCustomBuildMarkerIsEmbedded(t *testing.T) {
+ asset, ok := Assets()["default/index.html"]
+ if !ok {
+ t.Fatal("default/index.html is missing from embedded GUI assets")
+ }
+
+ content := asset.Content
+ if asset.Gzipped {
+ reader, err := gzip.NewReader(strings.NewReader(asset.Content))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ content = string(data)
+ }
+
+ if !strings.Contains(content, customBuildMarker) {
+ t.Fatalf("embedded GUI assets do not contain custom build marker %q", customBuildMarker)
+ }
+}
diff --git a/lib/fs/filesystem.go b/lib/fs/filesystem.go
index 863a91333..34d7d18ec 100644
--- a/lib/fs/filesystem.go
+++ b/lib/fs/filesystem.go
@@ -295,8 +295,8 @@ func NewFilesystem(fsType FilesystemType, uri string, opts ...Option) Filesystem
}
// fs cannot import config or versioner, so we hard code .stfolder
-// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath)
-var internals = []string{".stfolder", ".stignore", ".stversions"}
+// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath).
+var internals = []string{".stfolder", ".stversions"}
// IsInternal returns true if the file, as a path relative to the folder
// root, represents an internal file that should always be ignored. The file
diff --git a/lib/fs/filesystem_test.go b/lib/fs/filesystem_test.go
index 80d0bed65..52eac7bc7 100644
--- a/lib/fs/filesystem_test.go
+++ b/lib/fs/filesystem_test.go
@@ -21,10 +21,8 @@ func TestIsInternal(t *testing.T) {
internal bool
}{
{".stfolder", true},
- {".stignore", true},
{".stversions", true},
{".stfolder/foo", true},
- {".stignore/foo", true},
{".stversions/foo", true},
{".stfolderfoo", false},
@@ -34,6 +32,8 @@ func TestIsInternal(t *testing.T) {
{"foo.stignore", false},
{"foo.stversions", false},
{"foo/.stfolder", false},
+ {".stignore", false},
+ {".stignore/foo", false},
{"foo/.stignore", false},
{"foo/.stversions", false},
}
diff --git a/lib/model/folder_recvonly_test.go b/lib/model/folder_recvonly_test.go
index 6e3be2416..a025e8d27 100644
--- a/lib/model/folder_recvonly_test.go
+++ b/lib/model/folder_recvonly_test.go
@@ -60,15 +60,15 @@ func TestRecvOnlyRevertDeletes(t *testing.T) {
must(t, m.ScanFolder("ro"))
- // We should now have two files and two directories, with global state unchanged.
+ // We should now have three files and two directories, with global state unchanged.
size = mustV(m.GlobalSize("ro"))
if size.Files != 1 || size.Directories != 1 {
t.Fatalf("Global: expected 1 file and 1 directory: %+v", size)
}
size = mustV(m.LocalSize("ro", protocol.LocalDeviceID))
- if size.Files != 2 || size.Directories != 2 {
- t.Fatalf("Local: expected 2 files and 2 directories: %+v", size)
+ if size.Files != 3 || size.Directories != 2 {
+ t.Fatalf("Local: expected 3 files and 2 directories: %+v", size)
}
size = mustV(m.ReceiveOnlySize("ro"))
if size.Files+size.Directories == 0 {
diff --git a/lib/model/requests_test.go b/lib/model/requests_test.go
index c4e7fdc34..e1ee1f98d 100644
--- a/lib/model/requests_test.go
+++ b/lib/model/requests_test.go
@@ -974,13 +974,15 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
file := "foobar"
contents := []byte("test file contents\n")
- basicCheck := func(fs []protocol.FileInfo) {
+ basicCheck := func(fs []protocol.FileInfo) protocol.FileInfo {
t.Helper()
- if len(fs) != 1 {
- t.Fatal("expected a single index entry, got", len(fs))
- } else if fs[0].Name != file {
- t.Fatalf("expected a index entry for %v, got one for %v", file, fs[0].Name)
+ for _, f := range fs {
+ if f.Name == file {
+ return f
+ }
}
+ t.Fatalf("expected an index entry for %v, got %v", file, fs)
+ return protocol.FileInfo{}
}
done := make(chan struct{})
@@ -1001,8 +1003,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
- basicCheck(fs)
- f := fs[0]
+ f := basicCheck(fs)
if !f.IsInvalid() {
t.Errorf("Received non-invalid index update")
}
@@ -1022,8 +1023,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
- basicCheck(fs)
- f := fs[0]
+ f := basicCheck(fs)
if f.IsInvalid() {
t.Errorf("Received invalid index update")
}
diff --git a/lib/scanner/walk_test.go b/lib/scanner/walk_test.go
index 3acf9da95..a11ab55b1 100644
--- a/lib/scanner/walk_test.go
+++ b/lib/scanner/walk_test.go
@@ -40,6 +40,7 @@ type testfile struct {
type testfileList []testfile
var testdata = testfileList{
+ {".stignore", 48, "f60db5c36f642f8a6c1a636024330cd4dba6ab965083542f3629ff7d8c547911"},
{"afile", 4, "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
{"dir1", 0, ""},
{filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 000000000..6384d1e03
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,51 @@
+# Local Syncthing Patches
+
+Apply the local patches manually after pulling a new upstream Syncthing release:
+
+```bash
+git apply patches/sync-stignore.patch patches/webui-build-marker.patch
+go run build.go -build-out bin/syncthing-stignore build syncthing
+```
+
+The automated release flow uses:
+
+```bash
+./scripts/update-custom-release.sh
+```
+
+By default the script finds the latest stable upstream tag, creates a local
+`custom/-` branch, applies all local patches, removes upstream
+GitHub/Gitea workflow files from the release commit, tags
+`-stignore.7`, regenerates embedded GUI assets, runs focused tests,
+and writes build artifacts to `dist/`.
+
+Useful options:
+
+```bash
+CUSTOM_RELEASE_UPSTREAM_TAG=v2.1.0 ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_FORCE=1 ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_PATCHES="patches/sync-stignore.patch patches/webui-build-marker.patch" ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_PUSH=1 CUSTOM_RELEASE_REMOTE=gitea ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_PUSH=1 CUSTOM_RELEASE_PUSH_BRANCH=1 CUSTOM_RELEASE_REMOTE=gitea ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_CREATE_GITEA_RELEASE=1 CUSTOM_RELEASE_TEA_REPO=felixfoertsch/syncthing ./scripts/update-custom-release.sh
+CUSTOM_RELEASE_BUILDS="darwin/amd64/zip darwin/arm64/zip linux/amd64/tar linux/arm64/tar" ./scripts/update-custom-release.sh
+```
+
+The Gitea and GitHub workflows run the script on their respective CI hosts when
+the local patchset changes on `main`, on a schedule, and on manual dispatch.
+The repository's `upstream` branch stays a clean upstream mirror.
+Before building, each host updates its own `upstream` branch from official
+Syncthing `main` and rebuilds its patched `main` directly on that commit.
+The workflow detects the latest upstream Syncthing stable tag, exits if the
+matching custom tag already exists, pushes only the `-stignore.7` tag
+by default, and publishes release assets on the CI host running the workflow.
+The local `custom/` branch is only pushed when
+`CUSTOM_RELEASE_PUSH_BRANCH=1` is set. The workflow builds macOS arm64, Linux
+amd64, and Linux arm64 archives. Both hosts sign their macOS builds with their
+configured Developer ID certificate.
+
+The patch makes root-level `.stignore` sync like regular folder content while
+keeping `.stfolder` and `.stversions` protected as internal Syncthing paths.
+The web UI patch adds `It syncs .stignore now!` to the GUI footer so the custom
+binary is visually distinguishable from upstream builds. The release test fails
+if the generated GUI asset bundle does not contain that marker.
diff --git a/patches/sync-stignore.patch b/patches/sync-stignore.patch
new file mode 100644
index 000000000..db5d51cbe
--- /dev/null
+++ b/patches/sync-stignore.patch
@@ -0,0 +1,135 @@
+diff --git a/README.md b/README.md
+index 2f5c18f8e..197150ff0 100644
+--- a/README.md
++++ b/README.md
+@@ -1,3 +1,11 @@
++# Syncthing with `.stignore` synchronization
++
++This fork synchronizes the root-level `.stignore` file as regular folder
++content, while keeping `.stfolder` and `.stversions` protected as Syncthing
++internals. The `upstream` branch mirrors the official Syncthing `main` branch;
++this fork's `main` branch and releases apply the `.stignore` synchronization
++patch.
++
+ [![Syncthing][14]][15]
+
+ ---
+diff --git a/lib/fs/filesystem.go b/lib/fs/filesystem.go
+index 3800bef9c..8e6ecea2e 100644
+--- a/lib/fs/filesystem.go
++++ b/lib/fs/filesystem.go
+@@ -295,8 +295,8 @@ func NewFilesystem(fsType FilesystemType, uri string, opts ...Option) Filesystem
+ }
+
+ // fs cannot import config or versioner, so we hard code .stfolder
+-// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath)
+-var internals = []string{".stfolder", ".stignore", ".stversions"}
++// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath).
++var internals = []string{".stfolder", ".stversions"}
+
+ // IsInternal returns true if the file, as a path relative to the folder
+ // root, represents an internal file that should always be ignored. The file
+diff --git a/lib/fs/filesystem_test.go b/lib/fs/filesystem_test.go
+index d8d86dfac..51995499a 100644
+--- a/lib/fs/filesystem_test.go
++++ b/lib/fs/filesystem_test.go
+@@ -21,10 +21,8 @@ func TestIsInternal(t *testing.T) {
+ internal bool
+ }{
+ {".stfolder", true},
+- {".stignore", true},
+ {".stversions", true},
+ {".stfolder/foo", true},
+- {".stignore/foo", true},
+ {".stversions/foo", true},
+
+ {".stfolderfoo", false},
+@@ -34,6 +32,8 @@ func TestIsInternal(t *testing.T) {
+ {"foo.stignore", false},
+ {"foo.stversions", false},
+ {"foo/.stfolder", false},
++ {".stignore", false},
++ {".stignore/foo", false},
+ {"foo/.stignore", false},
+ {"foo/.stversions", false},
+ }
+diff --git a/lib/scanner/walk_test.go b/lib/scanner/walk_test.go
+index 4642a52cc..7226f8ad6 100644
+--- a/lib/scanner/walk_test.go
++++ b/lib/scanner/walk_test.go
+@@ -40,6 +40,7 @@ type testfile struct {
+ type testfileList []testfile
+
+ var testdata = testfileList{
++ {".stignore", 48, "f60db5c36f642f8a6c1a636024330cd4dba6ab965083542f3629ff7d8c547911"},
+ {"afile", 4, "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
+ {"dir1", 0, ""},
+ {filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
+diff --git a/lib/model/folder_recvonly_test.go b/lib/model/folder_recvonly_test.go
+index e7b37589a..c8cc9209d 100644
+--- a/lib/model/folder_recvonly_test.go
++++ b/lib/model/folder_recvonly_test.go
+@@ -60,15 +60,15 @@ func TestRecvOnlyRevertDeletes(t *testing.T) {
+
+ must(t, m.ScanFolder("ro"))
+
+- // We should now have two files and two directories, with global state unchanged.
++ // We should now have three files and two directories, with global state unchanged.
+
+ size = mustV(m.GlobalSize("ro"))
+ if size.Files != 1 || size.Directories != 1 {
+ t.Fatalf("Global: expected 1 file and 1 directory: %+v", size)
+ }
+ size = mustV(m.LocalSize("ro", protocol.LocalDeviceID))
+- if size.Files != 2 || size.Directories != 2 {
+- t.Fatalf("Local: expected 2 files and 2 directories: %+v", size)
++ if size.Files != 3 || size.Directories != 2 {
++ t.Fatalf("Local: expected 3 files and 2 directories: %+v", size)
+ }
+ size = mustV(m.ReceiveOnlySize("ro"))
+ if size.Files+size.Directories == 0 {
+diff --git a/lib/model/requests_test.go b/lib/model/requests_test.go
+index 72f7ba3d8..6f8124e97 100644
+--- a/lib/model/requests_test.go
++++ b/lib/model/requests_test.go
+@@ -974,13 +974,15 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
+ file := "foobar"
+ contents := []byte("test file contents\n")
+
+- basicCheck := func(fs []protocol.FileInfo) {
++ basicCheck := func(fs []protocol.FileInfo) protocol.FileInfo {
+ t.Helper()
+- if len(fs) != 1 {
+- t.Fatal("expected a single index entry, got", len(fs))
+- } else if fs[0].Name != file {
+- t.Fatalf("expected a index entry for %v, got one for %v", file, fs[0].Name)
++ for _, f := range fs {
++ if f.Name == file {
++ return f
++ }
+ }
++ t.Fatalf("expected an index entry for %v, got %v", file, fs)
++ return protocol.FileInfo{}
+ }
+
+ done := make(chan struct{})
+@@ -1001,8 +1003,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
+
+ done = make(chan struct{})
+ fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
+- basicCheck(fs)
+- f := fs[0]
++ f := basicCheck(fs)
+ if !f.IsInvalid() {
+ t.Errorf("Received non-invalid index update")
+ }
+@@ -1022,8 +1023,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
+
+ done = make(chan struct{})
+ fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
+- basicCheck(fs)
+- f := fs[0]
++ f := basicCheck(fs)
+ if f.IsInvalid() {
+ t.Errorf("Received invalid index update")
+ }
diff --git a/patches/webui-build-marker.patch b/patches/webui-build-marker.patch
new file mode 100644
index 000000000..fa7db15ab
--- /dev/null
+++ b/patches/webui-build-marker.patch
@@ -0,0 +1,62 @@
+diff --git a/gui/default/index.html b/gui/default/index.html
+index e3e573c9a..5ec76e740 100644
+--- a/gui/default/index.html
++++ b/gui/default/index.html
+@@ -1017,6 +1017,9 @@
+
+
+
++
+
+
+
+diff --git a/lib/api/auto/custom_marker_test.go b/lib/api/auto/custom_marker_test.go
+new file mode 100644
+index 000000000..73eae3db7
+--- /dev/null
++++ b/lib/api/auto/custom_marker_test.go
+@@ -0,0 +1,42 @@
++// Copyright (C) 2026 The Syncthing Authors.
++//
++// This Source Code Form is subject to the terms of the Mozilla Public
++// License, v. 2.0. If a copy of the MPL was not distributed with this file,
++// You can obtain one at https://mozilla.org/MPL/2.0/.
++
++package auto
++
++import (
++ "compress/gzip"
++ "io"
++ "strings"
++ "testing"
++)
++
++const customBuildMarker = "It syncs .stignore now!"
++
++func TestCustomBuildMarkerIsEmbedded(t *testing.T) {
++ asset, ok := Assets()["default/index.html"]
++ if !ok {
++ t.Fatal("default/index.html is missing from embedded GUI assets")
++ }
++
++ content := asset.Content
++ if asset.Gzipped {
++ reader, err := gzip.NewReader(strings.NewReader(asset.Content))
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer reader.Close()
++
++ data, err := io.ReadAll(reader)
++ if err != nil {
++ t.Fatal(err)
++ }
++ content = string(data)
++ }
++
++ if !strings.Contains(content, customBuildMarker) {
++ t.Fatalf("embedded GUI assets do not contain custom build marker %q", customBuildMarker)
++ }
++}
diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh
new file mode 100755
index 000000000..1f039e948
--- /dev/null
+++ b/scripts/sync-upstream.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+upstream_url="${SYNC_UPSTREAM_URL:-https://github.com/syncthing/syncthing.git}"
+remote="${SYNC_REMOTE:-origin}"
+upstream_branch="${SYNC_UPSTREAM_BRANCH:-upstream}"
+main_branch="${SYNC_MAIN_BRANCH:-main}"
+tmp=""
+
+die() {
+ printf 'error: %s\n' "$*" >&2
+ exit 1
+}
+
+cleanup() {
+ [[ -z "$tmp" ]] || rm -rf "$tmp"
+}
+
+main() {
+ [[ -z "$(git status --porcelain)" ]] || die "working tree has uncommitted changes"
+
+ local current_main
+ local current_upstream
+ local main_ahead_count
+ local main_parent
+ local new_upstream
+ local upstream_date
+ current_main="$(git rev-parse "refs/heads/$main_branch")"
+ current_upstream="$(git rev-parse "refs/remotes/$remote/$upstream_branch")"
+ main_ahead_count="$(git rev-list --count "$current_upstream..$current_main")"
+ main_parent="$(git rev-parse "$current_main^" 2>/dev/null || true)"
+
+ git fetch "$upstream_url" "refs/heads/main:refs/remotes/official/main"
+ new_upstream="$(git rev-parse refs/remotes/official/main)"
+ if [[ "$new_upstream" == "$current_upstream" && "$main_ahead_count" == "1" && "$main_parent" == "$current_upstream" ]]; then
+ printf 'Upstream is current and main is exactly one commit ahead at %s.\n' "$new_upstream"
+ return
+ fi
+ upstream_date="$(git show -s --format=%cI "$new_upstream")"
+
+ tmp="$(mktemp -d)"
+ trap cleanup EXIT
+ mkdir -p "$tmp/.gitea/workflows" "$tmp/.github/workflows" "$tmp/patches" "$tmp/scripts/tests"
+ cp .gitea/workflows/custom-release.yml "$tmp/.gitea/workflows/"
+ cp .github/workflows/custom-release.yml "$tmp/.github/workflows/"
+ cp patches/*.patch patches/README.md "$tmp/patches/"
+ cp scripts/update-custom-release.sh scripts/sync-upstream.sh "$tmp/scripts/"
+ cp scripts/tests/test-custom-release-macos-runner.bats "$tmp/scripts/tests/"
+
+ if [[ "$new_upstream" != "$current_upstream" ]]; then
+ git branch -f "$upstream_branch" "$new_upstream"
+ git push --force-with-lease="$upstream_branch:$current_upstream" "$remote" "$upstream_branch"
+ fi
+
+ git checkout -B "$main_branch" "$new_upstream"
+ cp -R "$tmp/.gitea" "$tmp/.github" "$tmp/patches" "$tmp/scripts" .
+ git apply patches/sync-stignore.patch patches/webui-build-marker.patch
+ git add -A
+ GIT_AUTHOR_DATE="$upstream_date" GIT_COMMITTER_DATE="$upstream_date" \
+ git -c user.name="Syncthing .stignore Fork" \
+ -c user.email="actions@felixfoertsch.de" \
+ commit -m "apply stignore synchronization patch"
+ git push --force-with-lease="$main_branch:$current_main" "$remote" "$main_branch"
+}
+
+main "$@"
diff --git a/scripts/tests/test-custom-release-macos-runner.bats b/scripts/tests/test-custom-release-macos-runner.bats
new file mode 100644
index 000000000..bfe6ff0c2
--- /dev/null
+++ b/scripts/tests/test-custom-release-macos-runner.bats
@@ -0,0 +1,252 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
+ WORKFLOW="$REPO_ROOT/.gitea/workflows/custom-release.yml"
+ GITHUB_WORKFLOW="$REPO_ROOT/.github/workflows/custom-release.yml"
+ RELEASE_SCRIPT="$REPO_ROOT/scripts/update-custom-release.sh"
+ SYNC_SCRIPT="$REPO_ROOT/scripts/sync-upstream.sh"
+}
+
+@test "both hosts mirror upstream before building their own release" {
+ run rg -n 'run: ./scripts/sync-upstream.sh' "$WORKFLOW" "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 2 ]
+
+ run rg -n 'git push --force-with-lease=.*upstream|git push --force-with-lease=.*main' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'git apply patches/sync-stignore.patch patches/webui-build-marker.patch' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'main_parent=.*current_main\^' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'main_parent.*current_upstream' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'main_ahead_count=.*rev-list --count' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'main_ahead_count.*== "1"' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'GIT_AUTHOR_DATE=.*GIT_COMMITTER_DATE=' "$SYNC_SCRIPT"
+ [ "$status" -eq 0 ]
+}
+
+@test "github custom release uses a github macos runner and github releases" {
+ run rg -n 'runs-on:[[:space:]]*macos-14' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'gh release create' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CREATE_GITEA_RELEASE: "0"' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_SIGN_DARWIN: "1"' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'tea|ffmini_macos_arm64' "$GITHUB_WORKFLOW"
+ [ "$status" -ne 0 ]
+}
+
+@test "github imports signing secrets into a disposable keychain" {
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_BASE64:.*secrets.DEVELOPER_ID_APPLICATION_P12_BASE64' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_PASSWORD:.*secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security import .* -P "\$DEVELOPER_ID_APPLICATION_P12_PASSWORD"' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n '::add-mask::\$keychain_password' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'if: always\(\)' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security delete-keychain "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'rm -f "\$CUSTOM_RELEASE_CERTIFICATE_PATH"' "$GITHUB_WORKFLOW"
+ [ "$status" -eq 0 ]
+}
+
+@test "custom release runs as one job on ffmini macos runner" {
+ run rg -n 'runs-on:[[:space:]]*ffmini_macos_arm64' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'ubuntu-latest' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'actions/upload-artifact' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+}
+
+@test "custom release tea login setup is idempotent on persistent host runner" {
+ run rg -n 'tea" logins delete actions >/dev/null 2>&1 \|\| true' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'tea" logins add --name actions' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+}
+
+@test "custom release workflow imports developer id signing material into temporary keychain" {
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_BASE64' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_PASSWORD' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_BASE64 secret is required' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'DEVELOPER_ID_APPLICATION_P12_PASSWORD secret is required' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security import .* -P "\$DEVELOPER_ID_APPLICATION_P12_PASSWORD"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security import .* -A ' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security find-identity -v -p codesigning "\$keychain_path"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security default-keychain -d user -s "\$keychain_path"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'previous_default_keychain=' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN=\$previous_default_keychain' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_PREVIOUS_DYNAMIC_DEFAULT_KEYCHAIN' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security default-keychain -s "\$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN"' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security default-keychain -d dynamic' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security find-identity -v -p codesigning$' "$WORKFLOW" "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'codesign_identity_sha1=' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'sed -n .*Developer ID Application' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY=\$codesign_identity' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY_SHA1=\$codesign_identity_sha1' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_KEYCHAIN_PASSWORD=\$keychain_password' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'codesign --force --dryrun --sign "\$codesign_identity" --keychain "\$keychain_path" --options runtime --timestamp "\$probe_binary"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY: "Developer ID Application' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security create-keychain' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'keychain_dir="\$HOME/Library/Keychains"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'rm -f "\$keychain_path"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'existing_keychains=\(\)' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security list-keychains -s "\$keychain_path"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security list-keychains -d dynamic' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+
+ run rg -n 'security import' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security delete-keychain' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+}
+
+@test "custom release carries per-target cgo mode" {
+ run rg -n 'darwin/arm64/zip/1' "$WORKFLOW" "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'linux/amd64/tar/0' "$WORKFLOW" "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CGO_ENABLED' "$WORKFLOW"
+ [ "$status" -ne 0 ]
+}
+
+@test "custom release signs darwin assets with hardened runtime and timestamp" {
+ run rg -n 'codesign_args=\(--force --sign "\$codesign_identity"\)' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n -- '--keychain "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'codesign_args\+=\(--options runtime --timestamp\)' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security unlock-keychain -p "\$CUSTOM_RELEASE_KEYCHAIN_PASSWORD" "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'security find-identity -v -p codesigning "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run awk '
+ /codesign_args=\(--force --sign "\$codesign_identity"\)/ { sign = NR }
+ /codesign_args\+=\(--keychain "\$CUSTOM_RELEASE_KEYCHAIN_PATH"\)/ { keychain = NR }
+ /codesign_args\+=\(--options runtime --timestamp\)/ { options = NR }
+ END { exit !(sign && keychain && options && sign < keychain && keychain < options) }
+ ' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'Developer ID Application' "$WORKFLOW" "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY' "$WORKFLOW" "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+}
+
+@test "custom release validates signed darwin binaries before publishing" {
+ run rg -n 'modernc-sqlite' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'codesign --verify --strict --verbose=2' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'TeamIdentifier=NG5W75WE8U|TeamIdentifier.*NG5W75WE8U' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT: "0"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'CUSTOM_RELEASE_SIGN_DARWIN: "1"' "$WORKFLOW"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'require_gatekeeper_assessment="\$\{CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT:-0\}"' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'if \[\[ "\$require_gatekeeper_assessment" == "1" \]\]; then' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+
+ run rg -n 'spctl -a -vv --type execute' "$RELEASE_SCRIPT"
+ [ "$status" -eq 0 ]
+}
diff --git a/scripts/update-custom-release.sh b/scripts/update-custom-release.sh
new file mode 100755
index 000000000..fd1f40099
--- /dev/null
+++ b/scripts/update-custom-release.sh
@@ -0,0 +1,417 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Description: create a patched Syncthing release from the latest upstream tag.
+# Usage: ./scripts/update-custom-release.sh
+
+upstream_url="${CUSTOM_RELEASE_UPSTREAM_URL:-https://github.com/syncthing/syncthing.git}"
+upstream_tag="${CUSTOM_RELEASE_UPSTREAM_TAG:-}"
+suffix="${CUSTOM_RELEASE_SUFFIX:-stignore.7}"
+branch_prefix="${CUSTOM_RELEASE_BRANCH_PREFIX:-custom}"
+dist_dir="${CUSTOM_RELEASE_DIST_DIR:-dist}"
+target="${CUSTOM_RELEASE_TARGET:-syncthing}"
+archive_kind="${CUSTOM_RELEASE_ARCHIVE:-tar}"
+build_specs="${CUSTOM_RELEASE_BUILDS:-}"
+default_cgo_enabled="${CUSTOM_RELEASE_CGO_ENABLED:-0}"
+codesign_identity="${CUSTOM_RELEASE_CODESIGN_IDENTITY:-}"
+codesign_team_id="${CUSTOM_RELEASE_CODESIGN_TEAM_ID:-NG5W75WE8U}"
+sign_darwin="${CUSTOM_RELEASE_SIGN_DARWIN:-1}"
+require_gatekeeper_assessment="${CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT:-0}"
+push_release="${CUSTOM_RELEASE_PUSH:-0}"
+push_branch="${CUSTOM_RELEASE_PUSH_BRANCH:-0}"
+push_remote="${CUSTOM_RELEASE_REMOTE:-origin}"
+run_tests="${CUSTOM_RELEASE_TEST:-1}"
+force="${CUSTOM_RELEASE_FORCE:-0}"
+publish_gitea_release="${CUSTOM_RELEASE_CREATE_GITEA_RELEASE:-0}"
+tea_repo="${CUSTOM_RELEASE_TEA_REPO:-}"
+patch_tmp_dir=""
+patch_files=()
+assets_rebuilt=0
+
+if [[ -n "${CUSTOM_RELEASE_PATCHES:-}" ]]; then
+ read -r -a patch_files <<< "$CUSTOM_RELEASE_PATCHES"
+elif [[ -n "${CUSTOM_RELEASE_PATCH:-}" ]]; then
+ patch_files=("$CUSTOM_RELEASE_PATCH")
+else
+ patch_files=(
+ patches/sync-stignore.patch
+ patches/webui-build-marker.patch
+ )
+fi
+
+log() {
+ printf '%s\n' "$*"
+}
+
+die() {
+ printf 'error: %s\n' "$*" >&2
+ exit 1
+}
+
+require_clean_worktree() {
+ if [[ -n "$(git status --porcelain)" ]]; then
+ die "working tree has uncommitted changes"
+ fi
+}
+
+latest_stable_tag() {
+ git ls-remote --refs --tags --sort='version:refname' "$upstream_url" 'v[0-9]*' \
+ | awk '{ tag = $2; sub("refs/tags/", "", tag); if (tag ~ /^v[0-9]+\.[0-9]+\.[0-9]+$/) latest = tag } END { print latest }'
+}
+
+tag_exists() {
+ local tag="$1"
+
+ if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
+ return 0
+ fi
+
+ git ls-remote --quiet --exit-code --tags "$push_remote" "refs/tags/$tag" >/dev/null 2>&1
+}
+
+copy_patches_to_temp() {
+ local patch_tmp_dir="$1"
+ local patch_file
+ local patch_name
+ local patch_index=0
+
+ mkdir -p "$patch_tmp_dir"
+ for patch_file in "${patch_files[@]}"; do
+ [[ -f "$patch_file" ]] || die "patch file not found: $patch_file"
+ printf -v patch_name '%03d-%s' "$patch_index" "$(basename "$patch_file")"
+ cp "$patch_file" "$patch_tmp_dir/$patch_name"
+ patch_index=$((patch_index + 1))
+ done
+}
+
+fetch_upstream_tag() {
+ local tag="$1"
+
+ git fetch --force "$upstream_url" "refs/tags/$tag:refs/tags/$tag"
+}
+
+create_release_commit() {
+ local tag="$1"
+ local custom_tag="$2"
+ local branch="$3"
+ local patch_tmp_dir="$4"
+ local patch_file
+
+ git checkout -B "$branch" "$tag"
+ rm -rf "$dist_dir"
+ for patch_file in "$patch_tmp_dir"/*; do
+ [[ -f "$patch_file" ]] || continue
+ log "Applying $(basename "$patch_file")"
+ git apply --3way "$patch_file"
+ done
+ remove_upstream_workflows
+ git add -A
+ git commit -m "apply local Syncthing patches for $tag"
+ git tag -a "$custom_tag" -m "Syncthing $tag with local patches"
+}
+
+remove_upstream_workflows() {
+ local workflow_dir
+
+ for workflow_dir in .github/workflows .gitea/workflows; do
+ if [[ -e "$workflow_dir" ]]; then
+ log "Removing upstream workflow directory $workflow_dir from release commit"
+ rm -rf "$workflow_dir"
+ fi
+ done
+}
+
+delete_local_tag_if_forced() {
+ local tag="$1"
+
+ if [[ "$force" != "1" ]]; then
+ return
+ fi
+ if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
+ git tag -d "$tag"
+ fi
+}
+
+test_release() {
+ if [[ "$run_tests" != "1" ]]; then
+ log "Skipping tests because CUSTOM_RELEASE_TEST=$run_tests"
+ return
+ fi
+
+ rebuild_assets_once
+ go test ./lib/api/auto ./lib/fs ./lib/ignore ./lib/scanner ./lib/model
+}
+
+rebuild_assets_once() {
+ if [[ "$assets_rebuilt" == "1" ]]; then
+ return
+ fi
+
+ log "Regenerating embedded GUI assets"
+ go run build.go assets
+ assets_rebuilt=1
+}
+
+format_build_specs() {
+ local spec
+
+ for spec in $build_specs; do
+ printf -- '- %s\n' "$spec"
+ done
+}
+
+build_release() {
+ local custom_tag="$1"
+
+ rebuild_assets_once
+ rm -rf "$dist_dir"
+ mkdir -p "$dist_dir"
+
+ if [[ -z "$build_specs" ]]; then
+ build_specs="$(go env GOOS)/$(go env GOARCH)/$archive_kind"
+ fi
+
+ local spec
+ for spec in $build_specs; do
+ build_one "$custom_tag" "$spec"
+ done
+
+ cat > "$dist_dir/release-notes.md" </dev/null 2>&1; then
+ sha256sum ./* > SHA256SUMS
+ else
+ shasum -a 256 ./* > SHA256SUMS
+ fi
+ )
+}
+
+build_one() {
+ local custom_tag="$1"
+ local spec="$2"
+ local goos
+ local goarch
+ local kind
+ local cgo_enabled
+
+ IFS=/ read -r goos goarch kind cgo_enabled <<< "$spec"
+ [[ -n "$goos" && -n "$goarch" && -n "$kind" ]] || die "invalid build spec: $spec"
+ cgo_enabled="${cgo_enabled:-$default_cgo_enabled}"
+
+ log "Building $target for $goos/$goarch as $kind with CGO_ENABLED=$cgo_enabled"
+
+ case "$kind" in
+ tar|zip)
+ local archive
+ archive="$(CGO_ENABLED="$cgo_enabled" go run build.go -version "$custom_tag" -goos "$goos" -goarch "$goarch" "$kind" "$target" | tail -n 1)"
+ if [[ "$goos" == "darwin" && "$sign_darwin" == "1" ]]; then
+ sign_and_validate_darwin_archive "$archive" "$kind"
+ fi
+ mv "$archive" "$dist_dir/"
+ ;;
+ binary)
+ CGO_ENABLED="$cgo_enabled" go run build.go -version "$custom_tag" -goos "$goos" -goarch "$goarch" -build-out "$dist_dir/$target-$goos-$goarch" build "$target"
+ if [[ "$goos" == "darwin" && "$sign_darwin" == "1" ]]; then
+ sign_and_validate_darwin_binary "$dist_dir/$target-$goos-$goarch"
+ fi
+ ;;
+ *)
+ die "unknown build archive kind in $spec"
+ ;;
+ esac
+}
+
+find_release_binary() {
+ local root="$1"
+ local candidate
+
+ while IFS= read -r candidate; do
+ if [[ -x "$candidate" ]]; then
+ printf '%s\n' "$candidate"
+ return
+ fi
+ done < <(find "$root" -type f -name "$target")
+
+ die "could not find executable $target in $root"
+}
+
+sign_and_validate_darwin_archive() {
+ local archive="$1"
+ local kind="$2"
+ local tmp
+ local archive_abs
+ local binary
+
+ [[ -n "$codesign_identity" ]] || die "CUSTOM_RELEASE_CODESIGN_IDENTITY is required for darwin builds"
+
+ tmp="$(mktemp -d)"
+ archive_abs="$(cd "$(dirname "$archive")" && pwd -P)/$(basename "$archive")"
+ case "$kind" in
+ zip)
+ unzip -q "$archive_abs" -d "$tmp"
+ ;;
+ tar)
+ tar -xf "$archive_abs" -C "$tmp"
+ ;;
+ *)
+ die "cannot sign archive kind $kind"
+ ;;
+ esac
+
+ binary="$(find_release_binary "$tmp")"
+ sign_and_validate_darwin_binary "$binary"
+
+ rm -f "$archive_abs"
+ case "$kind" in
+ zip)
+ (
+ cd "$tmp"
+ zip -qr "$archive_abs" .
+ )
+ ;;
+ tar)
+ (
+ cd "$tmp"
+ tar -czf "$archive_abs" .
+ )
+ ;;
+ esac
+ rm -rf "$tmp"
+}
+
+sign_and_validate_darwin_binary() {
+ local binary="$1"
+ local version_output
+ local codesign_details
+ local codesign_args
+
+ [[ -n "$codesign_identity" ]] || die "CUSTOM_RELEASE_CODESIGN_IDENTITY is required for darwin builds"
+
+ codesign_args=(--force --sign "$codesign_identity")
+ if [[ -n "${CUSTOM_RELEASE_KEYCHAIN_PATH:-}" ]]; then
+ if [[ -n "${CUSTOM_RELEASE_KEYCHAIN_PASSWORD:-}" ]]; then
+ security unlock-keychain -p "$CUSTOM_RELEASE_KEYCHAIN_PASSWORD" "$CUSTOM_RELEASE_KEYCHAIN_PATH"
+ fi
+ security find-identity -v -p codesigning "$CUSTOM_RELEASE_KEYCHAIN_PATH"
+ security find-identity -v -p codesigning
+ codesign_args+=(--keychain "$CUSTOM_RELEASE_KEYCHAIN_PATH")
+ fi
+ codesign_args+=(--options runtime --timestamp)
+ codesign "${codesign_args[@]}" "$binary"
+
+ version_output="$("$binary" --version)"
+ if [[ "$version_output" == *modernc-sqlite* ]]; then
+ die "darwin build unexpectedly reports [modernc-sqlite]: $version_output"
+ fi
+
+ codesign --verify --strict --verbose=2 "$binary"
+ codesign_details="$(codesign -dv --verbose=4 "$binary" 2>&1)"
+ if [[ "$codesign_team_id" == "NG5W75WE8U" && "$codesign_details" != *"TeamIdentifier=NG5W75WE8U"* ]]; then
+ printf '%s\n' "$codesign_details" >&2
+ die "darwin build is not signed by TeamIdentifier=NG5W75WE8U"
+ fi
+ if [[ "$codesign_details" != *"TeamIdentifier=$codesign_team_id"* ]]; then
+ printf '%s\n' "$codesign_details" >&2
+ die "darwin build is not signed by TeamIdentifier=$codesign_team_id"
+ fi
+ if [[ "$require_gatekeeper_assessment" == "1" ]]; then
+ spctl -a -vv --type execute "$binary"
+ else
+ log "Skipping Gatekeeper assessment because CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT=$require_gatekeeper_assessment"
+ fi
+}
+
+push_refs() {
+ local branch="$1"
+ local custom_tag="$2"
+
+ if [[ "$push_release" != "1" ]]; then
+ log "Skipping push because CUSTOM_RELEASE_PUSH=$push_release"
+ return
+ fi
+
+ if [[ "$push_branch" == "1" ]]; then
+ git push "$push_remote" "$branch"
+ else
+ log "Skipping release branch push because CUSTOM_RELEASE_PUSH_BRANCH=$push_branch"
+ fi
+ git push "$push_remote" "$custom_tag"
+}
+
+publish_release() {
+ local custom_tag="$1"
+
+ if [[ "$publish_gitea_release" != "1" ]]; then
+ log "Skipping Gitea release publishing because CUSTOM_RELEASE_CREATE_GITEA_RELEASE=$publish_gitea_release"
+ return
+ fi
+
+ command -v tea >/dev/null 2>&1 || die "tea is required for Gitea release publishing"
+
+ local args=(releases create "$custom_tag" --title "$custom_tag" --note-file "$dist_dir/release-notes.md")
+ if [[ -n "$tea_repo" ]]; then
+ args+=(--repo "$tea_repo")
+ else
+ args+=(--remote "$push_remote")
+ fi
+
+ local asset
+ for asset in "$dist_dir"/*; do
+ [[ -f "$asset" ]] || continue
+ [[ "$(basename "$asset")" == "release-notes.md" ]] && continue
+ args+=(--asset "$asset")
+ done
+
+ tea "${args[@]}"
+}
+
+main() {
+ require_clean_worktree
+
+ if [[ -z "$upstream_tag" ]]; then
+ upstream_tag="$(latest_stable_tag)"
+ fi
+ [[ -n "$upstream_tag" ]] || die "could not determine latest upstream tag"
+
+ local custom_tag="${upstream_tag}-${suffix}"
+ local branch="${branch_prefix}/${upstream_tag#v}-${suffix}"
+
+ if tag_exists "$custom_tag" && [[ "$force" != "1" ]]; then
+ log "Custom release $custom_tag already exists; nothing to do."
+ return
+ fi
+
+ patch_tmp_dir="$(mktemp -d)"
+ trap 'rm -rf "$patch_tmp_dir"' EXIT
+
+ copy_patches_to_temp "$patch_tmp_dir"
+ fetch_upstream_tag "$upstream_tag"
+ delete_local_tag_if_forced "$custom_tag"
+ create_release_commit "$upstream_tag" "$custom_tag" "$branch" "$patch_tmp_dir"
+ test_release
+ build_release "$custom_tag"
+ push_refs "$branch" "$custom_tag"
+ publish_release "$custom_tag"
+
+ log "Built custom release $custom_tag from upstream $upstream_tag"
+ log "Artifacts are in $dist_dir/"
+}
+
+main "$@"