chore: migrate mail themes to shared framework and locked upstream inputs
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 18:34:36 +08:00
1 parent 5c0589f6f6
commit fec3ace600
225 files changed
+4718 -15807

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"time"
)
func CacheDir(root string) string { return filepath.Join(root, "build", "upstream") }
func ReadLock(root string) (Lock, error) {
var lock Lock
b, err := os.ReadFile(filepath.Join(root, "gitea.lock.json"))
if err != nil {
return lock, err
}
if err = json.Unmarshal(b, &lock); err != nil {
return lock, err
}
return lock, lock.validateIdentity()
}
func LoadPinned(root string) (*Snapshot, error) {
lock, err := ReadLock(root)
if err != nil {
return nil, err
}
s, err := Load(CacheDir(root))
if err != nil {
return nil, err
}
if !reflect.DeepEqual(lock, s.Lock) {
return nil, fmt.Errorf("upstream cache differs from gitea.lock.json; run upstream prepare after reviewing the lock")
}
return s, nil
}
// Prepare fetches immutable commit files only if the cache is absent. A corrupt
// cache is an error, not permission to silently replace local evidence.
func Prepare(ctx context.Context, root string) (*Snapshot, error) {
return prepareFrom(ctx, root, "https://raw.githubusercontent.com/go-gitea/gitea/")
}
func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
lock, err := ReadLock(root)
if err != nil {
return nil, err
}
if _, err = os.Stat(filepath.Join(CacheDir(root), "lock.json")); err == nil {
return LoadPinned(root)
} else if !os.IsNotExist(err) {
return nil, err
}
client := &http.Client{Timeout: 45 * time.Second}
s := &Snapshot{Lock: lock, Files: map[string][]byte{}}
for name, hash := range lock.Files {
if !SafePath(name) {
return nil, fmt.Errorf("unsafe locked path %q", name)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawBase+lock.Commit+"/"+name, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GiteaMailTemplates-locked-cache")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", name, resp.StatusCode)
}
if Digest(b) != hash {
return nil, fmt.Errorf("download checksum mismatch: %s", name)
}
s.Files[name] = b
}
if err = s.validate(); err != nil {
return nil, err
}
dir := CacheDir(root)
if err = os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
return nil, err
}
stage, err := os.MkdirTemp(filepath.Dir(dir), ".upstream-cache-")
if err != nil {
return nil, err
}
defer os.RemoveAll(stage)
for name, b := range s.Files {
target := filepath.Join(stage, filepath.FromSlash(name))
if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return nil, err
}
if err = os.WriteFile(target, b, 0644); err != nil {
return nil, err
}
}
b, _ := json.MarshalIndent(lock, "", " ")
if err = os.WriteFile(filepath.Join(stage, "lock.json"), append(b, '\n'), 0644); err != nil {
return nil, err
}
if _, err = os.Stat(dir); err == nil {
return nil, fmt.Errorf("incomplete upstream cache exists at %s; inspect it before removing it", dir)
}
if err = os.Rename(stage, dir); err != nil {
return nil, err
}
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
return LoadPinned(root)
}
func SyncRoot(ctx context.Context, tag, root string) error {
if err := os.MkdirAll(filepath.Join(root, "build"), 0755); err != nil {
return err
}
if err := Sync(ctx, tag, CacheDir(root)); err != nil {
return err
}
s, err := Load(CacheDir(root))
if err != nil {
return err
}
b, err := json.MarshalIndent(s.Lock, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "gitea.lock.json"), append(b, '\n'), 0644)
}
+53
View File
@@ -0,0 +1,53 @@
package upstream
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrepareCacheIsPinnedAndReusableOffline(t *testing.T) {
s, err := LoadPinned(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
b, _ := json.Marshal(s.Lock)
if err = os.WriteFile(filepath.Join(root, "gitea.lock.json"), b, 0644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prefix := "/" + s.Lock.Commit + "/"
if !strings.HasPrefix(r.URL.Path, prefix) {
t.Errorf("unpinned request %s", r.URL.Path)
http.NotFound(w, r)
return
}
data, ok := s.Files[strings.TrimPrefix(r.URL.Path, prefix)]
if !ok {
http.NotFound(w, r)
return
}
w.Write(data)
}))
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
server.Close()
t.Fatal(err)
}
server.Close()
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
t.Fatalf("offline cache: %v", err)
}
path := filepath.Join(CacheDir(root), "templates", "mail", "base", "head.tmpl")
if err = os.WriteFile(path, []byte("corrupt"), 0644); err != nil {
t.Fatal(err)
}
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err == nil {
t.Fatal("corrupt cache was accepted")
}
}
+8
View File
@@ -0,0 +1,8 @@
package upstream
// ReviewedFormattingIssue is deliberately matched by language, key and exact
// upstream text. It cannot hide new defects after a snapshot update. Preview
// preserves Gitea's output and advertises this upstream defect to the user.
func ReviewedFormattingIssue(lang, key, value string) bool {
return lang == "pl-PL" && key == "mail.team_invite.text_1" && value == "%[1]zaprasza Cię do dołączenia do zespołu %[2]s w organizacji %[3]s."
}
+148
View File
@@ -0,0 +1,148 @@
package upstream
import (
"fmt"
"sort"
"text/template"
"text/template/parse"
)
// ReferencedKeys walks Go's template AST, including nested pipelines and both
// TrN keys. Dynamic translation keys require an explicit adapter review.
func ReferencedKeys(s *Snapshot) ([]string, error) {
fns := template.FuncMap{}
for _, name := range []string{"AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", "PathEscape", "SanitizeHTML"} {
fns[name] = func(...any) string { return "" }
}
keys := map[string]bool{}
var walk func(parse.Node) error
walk = func(n parse.Node) error {
if n == nil {
return nil
}
switch n := n.(type) {
case *parse.ListNode:
if n != nil {
for _, child := range n.Nodes {
if err := walk(child); err != nil {
return err
}
}
}
case *parse.ActionNode:
return walk(n.Pipe)
case *parse.PipeNode:
if n == nil {
return nil
}
for _, cmd := range n.Cmds {
if err := walk(cmd); err != nil {
return err
}
}
case *parse.CommandNode:
if len(n.Args) > 0 {
var ident []string
switch f := n.Args[0].(type) {
case *parse.FieldNode:
ident = f.Ident
case *parse.VariableNode:
ident = f.Ident
}
if len(ident) >= 2 && ident[len(ident)-2] == "locale" {
var positions []int
switch ident[len(ident)-1] {
case "Tr", "TrString":
positions = []int{1}
case "TrN":
positions = []int{2, 3}
}
for _, i := range positions {
if i >= len(n.Args) {
return fmt.Errorf("invalid translation call: %s", n)
}
key, ok := n.Args[i].(*parse.StringNode)
if !ok {
return fmt.Errorf("dynamic translation key needs review: %s", n)
}
keys[key.Text] = true
}
}
}
for _, arg := range n.Args {
if err := walk(arg); err != nil {
return err
}
}
case *parse.IfNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.RangeNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.WithNode:
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
if err := walk(child); err != nil {
return err
}
}
case *parse.TemplateNode:
return walk(n.Pipe)
}
return nil
}
for p, data := range s.Templates {
t, err := template.New(p).Funcs(fns).Parse(string(data))
if err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
for _, t := range t.Templates() {
if t.Tree != nil {
if err := walk(t.Tree.Root); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
}
}
}
var result []string
for k := range keys {
result = append(result, k)
}
sort.Strings(result)
return result, nil
}
func ValidateKeys(s *Snapshot) error {
keys, err := ReferencedKeys(s)
if err != nil {
return err
}
for _, k := range keys {
if s.Locales["en-US"][k] == "" {
return fmt.Errorf("official English catalog is missing referenced key %q", k)
}
}
return nil
}
func MissingKeys(s *Snapshot) (map[string][]string, error) {
keys, err := ReferencedKeys(s)
if err != nil {
return nil, err
}
missing := map[string][]string{}
for lang, catalog := range s.Locales {
for _, k := range keys {
if catalog[k] == "" {
missing[lang] = append(missing[lang], k)
}
}
}
return missing, nil
}
+211
View File
@@ -0,0 +1,211 @@
// Package upstream loads and explicitly synchronizes the reviewed Gitea snapshot.
package upstream
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
type Lock struct {
Schema int `json:"schema"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Commit string `json:"commit"`
Files map[string]string `json:"files"`
}
type Snapshot struct {
Lock Lock
Files map[string][]byte
Templates map[string][]byte // mail/... paths, including shared partials
Locales map[string]map[string]string
}
// AdapterSources also binds the preview's formatting and rendering semantics to
// the upstream implementation reviewed during this migration. A changed source
// requires reviewing the adapter before accepting another snapshot.
var AdapterSources = map[string]string{
"modules/translation/translation.go": "4217cbd9beb79c0e1be52dd9a7d7705b9ff4becc2d9515d49236db126da0f7ee",
"modules/translation/i18n/localestore.go": "e55a7832d01753622a7d47e904de95e5aed7ad3535c59bf6ac3fec4346e40e9d",
"modules/translation/i18n/format.go": "f440236eaf1f73fe0a9aa36a2c036a4b34c6ab28c9e4cf971bef7fceee701cb4",
"modules/templates/mail.go": "fb8204b79f700f88c7c3f0da16e50c3d678a2216e62e98f712805ed1d42ab3a9",
}
func Digest(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
func SafePath(p string) bool {
return p != "." && fs.ValidPath(p) && !strings.ContainsAny(p, "\\:")
}
func Load(dir string) (*Snapshot, error) {
s, err := loadFiles(dir)
if err != nil {
return nil, err
}
return s, s.validate()
}
// Ownership/checksum validation is separate so explicitly syncing after an
// adapter review can replace an older, still intact snapshot.
func loadFiles(dir string) (*Snapshot, error) {
data, err := os.ReadFile(filepath.Join(dir, "lock.json"))
if err != nil {
return nil, fmt.Errorf("snapshot lock: %w (run upstream sync explicitly)", err)
}
s := &Snapshot{Files: map[string][]byte{}}
if err := json.Unmarshal(data, &s.Lock); err != nil {
return nil, err
}
if err := s.Lock.validateIdentity(); err != nil {
return nil, err
}
for p, expected := range s.Lock.Files {
if !SafePath(p) {
return nil, fmt.Errorf("unsafe snapshot path %q", p)
}
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
if err != nil {
return nil, err
}
if Digest(b) != expected {
return nil, fmt.Errorf("snapshot checksum mismatch: %s", p)
}
s.Files[p] = b
}
// Unlocked files must not silently become input to a build.
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("snapshot symlinks are unsupported: %s", p)
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(dir, p)
rel = filepath.ToSlash(rel)
if rel != "lock.json" {
if _, ok := s.Files[rel]; !ok {
return fmt.Errorf("unlocked snapshot file: %s", rel)
}
}
return nil
})
if err != nil {
return nil, err
}
return s, nil
}
func (lock Lock) validateIdentity() error {
if lock.Schema != 1 || lock.Repository != "https://github.com/go-gitea/gitea" || !validTag(lock.Tag) || len(lock.Commit) != 40 || len(lock.Files) == 0 {
return fmt.Errorf("invalid snapshot identity or unsupported version")
}
if _, err := hex.DecodeString(lock.Commit); err != nil {
return fmt.Errorf("invalid snapshot commit: %w", err)
}
return nil
}
func (s *Snapshot) validate() error {
if err := s.Lock.validateIdentity(); err != nil {
return err
}
for p, hash := range AdapterSources {
if Digest(s.Files[p]) != hash {
return fmt.Errorf("upstream adapter changed: %s; review preview semantics before syncing", p)
}
}
if len(s.Files["LICENSE"]) == 0 {
return fmt.Errorf("missing upstream license")
}
s.Templates = map[string][]byte{}
s.Locales = map[string]map[string]string{}
for p, b := range s.Files {
switch {
case p == "public/assets/img/favicon.png":
if len(b) == 0 {
return fmt.Errorf("missing preview favicon")
}
case strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl"):
s.Templates[strings.TrimPrefix(p, "templates/")] = b
case strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"):
locale := strings.TrimSuffix(strings.TrimPrefix(p, "options/locale/locale_"), ".json")
values, err := DecodeLocale(b)
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
s.Locales[locale] = values
default:
if p != "LICENSE" {
if _, ok := AdapterSources[p]; !ok {
return fmt.Errorf("unsupported snapshot file: %s", p)
}
}
}
}
if len(s.Entrypoints()) == 0 || len(s.Templates["mail/base/head.tmpl"]) == 0 || len(s.Templates["mail/base/footer.tmpl"]) == 0 || len(s.Locales["en-US"]) == 0 {
return fmt.Errorf("snapshot is missing entrypoints, shared partials, or English translations")
}
return ValidateKeys(s)
}
// DecodeLocale mirrors Gitea's flat or one-level nested JSON catalog layout.
func DecodeLocale(data []byte) (map[string]string, error) {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
result := map[string]string{}
for k, v := range raw {
switch v := v.(type) {
case string:
result[k] = v
case map[string]any:
for inner, value := range v {
str, ok := value.(string)
if !ok {
return nil, fmt.Errorf("unsupported translation value: %s.%s", k, inner)
}
result[k+"."+inner] = str
}
default:
return nil, fmt.Errorf("unsupported translation value: %s", k)
}
}
return result, nil
}
func (s *Snapshot) Entrypoints() []string {
var result []string
for p := range s.Templates {
if !strings.HasPrefix(p, "mail/base/") {
result = append(result, p)
}
}
sort.Strings(result)
return result
}
func (s *Snapshot) Languages() []string {
var langs []string
for l := range s.Locales {
langs = append(langs, l)
}
sort.Strings(langs)
return langs
}
func TemplateID(p string) string { return strings.TrimSuffix(path.Base(p), ".tmpl") }
+86
View File
@@ -0,0 +1,86 @@
package upstream
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSnapshotAndKeys(t *testing.T) {
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
if s.Lock.Tag != "v28.0.0" || len(s.Templates) != 13 || len(s.Locales) != 28 {
t.Fatalf("unexpected baseline %s", s.Lock.Tag)
}
keys, err := ReferencedKeys(s)
if err != nil {
t.Fatal(err)
}
found := map[string]bool{}
for _, k := range keys {
found[k] = true
}
for _, k := range []string{"mail.issue.action.push_1", "mail.issue.action.push_n", "actions.runs.attempt"} {
if !found[k] {
t.Fatalf("missing key %s", k)
}
}
delete(s.Locales["en-US"], "actions.runs.attempt")
if ValidateKeys(s) == nil {
t.Fatal("missing English key accepted")
}
if !validTag("v28.0.0") || validTag("v1.27.3") || validTag("v28.1.0-rc1") {
t.Fatal("tag policy")
}
for _, p := range []string{"../x", "/x", "C:/x", "mail\\x"} {
if SafePath(p) {
t.Fatal("unsafe path accepted")
}
}
}
func TestSnapshotRejectsChecksumAndAdapterDrift(t *testing.T) {
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
for p, b := range s.Files {
target := filepath.Join(dir, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, b, 0644); err != nil {
t.Fatal(err)
}
}
writeLock := func() {
b, _ := json.Marshal(s.Lock)
if err := os.WriteFile(filepath.Join(dir, "lock.json"), b, 0644); err != nil {
t.Fatal(err)
}
}
writeLock()
p := "templates/mail/base/head.tmpl"
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
t.Fatal(err)
}
if _, err := Load(dir); err == nil {
t.Fatal("checksum mismatch accepted")
}
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), s.Files[p], 0644); err != nil {
t.Fatal(err)
}
p = "modules/translation/translation.go"
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
t.Fatal(err)
}
s.Lock.Files[p] = Digest([]byte("changed"))
writeLock()
if _, err := Load(dir); err == nil {
t.Fatal("changed translation adapter accepted despite updated checksum")
}
}
+164
View File
@@ -0,0 +1,164 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
var tagPattern = regexp.MustCompile(`^v([0-9]+)\.[0-9]+\.[0-9]+$`)
func validTag(tag string) bool {
m := tagPattern.FindStringSubmatch(tag)
if m == nil {
return false
}
major, err := strconv.Atoi(m[1])
return err == nil && major >= 28
}
// Sync downloads by resolved commit, validates in memory, then swaps the whole
// snapshot. Network/adapter failures leave the existing snapshot untouched.
func Sync(ctx context.Context, tag, dir string) error {
return syncFrom(ctx, tag, dir, "https://api.github.com/repos/go-gitea/gitea/", "https://raw.githubusercontent.com/go-gitea/gitea/")
}
func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
if !validTag(tag) {
return fmt.Errorf("expected a stable Gitea 28+ tag, e.g. v28.0.0")
}
client := &http.Client{Timeout: 45 * time.Second}
get := func(url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GiteaMailTemplates-snapshot")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
return b, err
}
b, err := get(api + "commits/" + tag)
if err != nil {
return err
}
var commit struct {
SHA string `json:"sha"`
Commit struct {
Tree struct {
SHA string `json:"sha"`
} `json:"tree"`
} `json:"commit"`
}
if err := json.Unmarshal(b, &commit); err != nil {
return err
}
b, err = get(api + "git/trees/" + commit.Commit.Tree.SHA + "?recursive=1")
if err != nil {
return err
}
var tree struct {
Truncated bool `json:"truncated"`
Tree []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"tree"`
}
if err := json.Unmarshal(b, &tree); err != nil {
return err
}
if tree.Truncated {
return fmt.Errorf("upstream tree is truncated")
}
s := &Snapshot{Lock: Lock{Schema: 1, Repository: "https://github.com/go-gitea/gitea", Tag: tag, Commit: commit.SHA, Files: map[string]string{}}, Files: map[string][]byte{}}
for _, item := range tree.Tree {
p := item.Path
_, adapter := AdapterSources[p]
selected := adapter || p == "LICENSE" || p == "public/assets/img/favicon.png" || (strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl")) || (strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"))
if !selected || item.Type != "blob" {
continue
}
b, err := get(raw + commit.SHA + "/" + p)
if err != nil {
return err
}
s.Files[p] = b
s.Lock.Files[p] = Digest(b)
}
if err := s.validate(); err != nil {
return err
}
abs, err := filepath.Abs(dir)
if err != nil {
return err
}
// Never replace a checkout or arbitrary non-snapshot directory.
if filepath.Base(abs) != "upstream" {
return fmt.Errorf("snapshot destination must be named upstream")
}
if entries, err := os.ReadDir(abs); err == nil && len(entries) > 0 {
if _, err := loadFiles(abs); err != nil {
return fmt.Errorf("refusing to replace invalid existing snapshot: %w", err)
}
}
stage, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-stage-")
if err != nil {
return err
}
defer os.RemoveAll(stage)
for p, data := range s.Files {
filename := filepath.Join(stage, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
return err
}
if err := os.WriteFile(filename, data, 0644); err != nil {
return err
}
}
b, err = json.MarshalIndent(s.Lock, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if err := os.WriteFile(filepath.Join(stage, "lock.json"), b, 0644); err != nil {
return err
}
if _, err := os.Stat(abs); err == nil {
backup, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-backup-")
if err != nil {
return err
}
if err := os.Remove(backup); err != nil {
return err
}
if err := os.Rename(abs, backup); err != nil {
return err
}
if err := os.Rename(stage, abs); err != nil {
_ = os.Rename(backup, abs)
return err
}
if err := os.RemoveAll(backup); err != nil {
return err
}
} else if err := os.Rename(stage, abs); err != nil {
return err
}
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
return nil
}