add reusable stignore sync release automation
Keep main as an upstream mirror while storing the local .stignore behavior, GUI marker, reusable patch files, and Gitea release workflow in one replayable branch commit.
This commit is contained in:
@@ -6,3 +6,6 @@ vendor/** -text=auto
|
||||
|
||||
# Diffs on these files are meaningless
|
||||
*.svg -diff
|
||||
|
||||
# Patch files intentionally contain diff context whitespace
|
||||
patches/*.patch -whitespace
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
name: custom release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
releases: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- felix/release-automation
|
||||
paths:
|
||||
- ".gitea/workflows/custom-release.yml"
|
||||
- "patches/**"
|
||||
- "scripts/update-custom-release.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.6"
|
||||
required: false
|
||||
schedule:
|
||||
- cron: "17 04 * * *"
|
||||
|
||||
jobs:
|
||||
build-custom-release:
|
||||
runs-on: ubuntu-latest
|
||||
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: 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 add --name actions --url https://git.felixfoertsch.de --token "$GITEA_TOKEN" --no-version-check
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- 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/amd64/zip darwin/arm64/zip linux/amd64/tar linux/arm64/tar"
|
||||
CUSTOM_RELEASE_CGO_ENABLED: "0"
|
||||
CUSTOM_RELEASE_CREATE_GITEA_RELEASE: "1"
|
||||
CUSTOM_RELEASE_TEA_REPO: felixfoertsch/syncthing
|
||||
@@ -18,3 +18,4 @@ deb
|
||||
/repos
|
||||
/proto/scripts/protoc-gen-gosyncthing
|
||||
/compat.json
|
||||
/dist/
|
||||
|
||||
@@ -284,8 +284,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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type testfile struct {
|
||||
type testfileList []testfile
|
||||
|
||||
var testdata = testfileList{
|
||||
{".stignore", 48, "f60db5c36f642f8a6c1a636024330cd4dba6ab965083542f3629ff7d8c547911"},
|
||||
{"afile", 4, "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
|
||||
{"dir1", 128, ""},
|
||||
{filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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/<version>-<suffix>` branch, applies all local patches, removes upstream
|
||||
GitHub/Gitea workflow files from the release commit, tags
|
||||
`<upstream>-stignore.6`, 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 workflow in `.gitea/workflows/custom-release.yml` runs the script when
|
||||
the local patchset changes on `felix/release-automation`, on a schedule, and on
|
||||
manual dispatch. The repository's `main` branch stays a clean upstream mirror.
|
||||
The workflow detects the latest upstream Syncthing stable tag, exits if the
|
||||
matching custom tag already exists, pushes only the `<upstream>-stignore.6` tag
|
||||
by default, and publishes the Gitea release assets.
|
||||
The local `custom/<version>` branch is only pushed when
|
||||
`CUSTOM_RELEASE_PUSH_BRANCH=1` is set. The workflow builds macOS amd64, macOS
|
||||
arm64, Linux amd64, and Linux arm64 archives.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,119 @@
|
||||
diff --git a/lib/fs/filesystem.go b/lib/fs/filesystem.go
|
||||
index 31bbb9770..dcece778e 100644
|
||||
--- a/lib/fs/filesystem.go
|
||||
+++ b/lib/fs/filesystem.go
|
||||
@@ -284,8 +284,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 08e45ff50..2d100a2b0 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 e84d0c49d..fbd442966 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 288140721..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/lib/scanner/walk_test.go b/lib/scanner/walk_test.go
|
||||
index d75dddaf3..3693ae4db 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", 128, ""},
|
||||
{filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
|
||||
@@ -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 @@
|
||||
</div> <!-- /row -->
|
||||
|
||||
</div> <!-- /container -->
|
||||
+ <footer class="container text-center text-muted small" aria-label="Custom build marker">
|
||||
+ It syncs .stignore now!
|
||||
+ </footer>
|
||||
</div> <!-- /ng-cloak -->
|
||||
|
||||
<ng-include src="'syncthing/core/networkErrorDialogView.html'"></ng-include>
|
||||
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)
|
||||
+ }
|
||||
+}
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/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.6}"
|
||||
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:-}"
|
||||
cgo_enabled="${CUSTOM_RELEASE_CGO_ENABLED:-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
|
||||
}
|
||||
|
||||
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" <<EOF
|
||||
# $custom_tag
|
||||
|
||||
Syncthing $upstream_tag with the local patches applied.
|
||||
|
||||
Builds:
|
||||
$(printf -- '- %s\n' $build_specs)
|
||||
|
||||
Patches:
|
||||
$(printf -- '- %s\n' "${patch_files[@]}")
|
||||
|
||||
Patch behavior:
|
||||
- Root-level .stignore syncs like regular folder content.
|
||||
- .stfolder and .stversions stay protected as Syncthing internals.
|
||||
- The web GUI footer includes "It syncs .stignore now!" as a custom build marker.
|
||||
EOF
|
||||
|
||||
(
|
||||
cd "$dist_dir"
|
||||
if command -v sha256sum >/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
|
||||
|
||||
IFS=/ read -r goos goarch kind <<< "$spec"
|
||||
[[ -n "$goos" && -n "$goarch" && -n "$kind" ]] || die "invalid build spec: $spec"
|
||||
|
||||
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)"
|
||||
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"
|
||||
;;
|
||||
*)
|
||||
die "unknown build archive kind in $spec"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user