diff --git a/.gitattributes b/.gitattributes index 1c25f9553..ae10cfbb0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.gitea/workflows/custom-release.yml b/.gitea/workflows/custom-release.yml new file mode 100644 index 000000000..1bd3fddd9 --- /dev/null +++ b/.gitea/workflows/custom-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 27113a847..7a62e71fd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ deb /repos /proto/scripts/protoc-gen-gosyncthing /compat.json +/dist/ 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"}, diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 000000000..ad4092cef --- /dev/null +++ b/patches/README.md @@ -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/-` branch, applies all local patches, removes upstream +GitHub/Gitea workflow files from the release commit, tags +`-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 `-stignore.6` tag +by default, and publishes the Gitea release assets. +The local `custom/` 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. diff --git a/patches/sync-stignore.patch b/patches/sync-stignore.patch new file mode 100644 index 000000000..6b4dce1ae --- /dev/null +++ b/patches/sync-stignore.patch @@ -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"}, 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/update-custom-release.sh b/scripts/update-custom-release.sh new file mode 100755 index 000000000..d302b397f --- /dev/null +++ b/scripts/update-custom-release.sh @@ -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" </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 "$@"