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 @@ + + + ++
++ It syncs .stignore now! ++
+ + + +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 "$@"