Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Check

# Single source of truth for "is this tree releasable": the full `just check` gate
# across the support matrix. Both CI (on push/PR) and Release (before goreleaser
# publishes) call this via workflow_call, so a tag can never ship from a tree whose
# checks are red — the gap that let v0.4.0 release with Windows CI failing.
on:
workflow_call:

jobs:
check:
name: just check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Install just
uses: taiki-e/install-action@v2
with:
tool: just

- name: Run checks
run: just check
27 changes: 1 addition & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,4 @@ on:

jobs:
check:
name: just check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Install just
uses: taiki-e/install-action@v2
with:
tool: just

- name: Run checks
run: just check
uses: ./.github/workflows/check.yml
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ permissions:
contents: write

jobs:
# Same matrix `just check` gate CI runs — release only proceeds if it is green,
# so goreleaser can never publish from a tree whose checks fail on any platform.
check:
uses: ./.github/workflows/check.yml

release:
name: GoReleaser
needs: check
runs-on: ubuntu-latest

steps:
Expand Down
30 changes: 28 additions & 2 deletions internal/cli/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"time"

"github.com/tpisel/memento/internal/brief"
"github.com/tpisel/memento/internal/enforce"
Expand Down Expand Up @@ -251,7 +252,8 @@ func writeBriefArtifact(v vault.Vault, m manifest.Manifest) error {
// 'brief' invocation racing on the same manifest/brief artifact — never observes a
// half-written file. The rename is atomic within a filesystem; the temp file shares
// path's directory to keep both sides on one. The temp is removed on any failure
// before the rename lands (memento-tbu.8).
// before the rename lands (memento-tbu.8). renameReplace, not a bare os.Rename,
// absorbs the transient Windows replace error two racers can provoke.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
Expand All @@ -272,13 +274,37 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
removeTmp()
return err
}
if err := os.Rename(tmpName, path); err != nil {
if err := renameReplace(tmpName, path); err != nil {
removeTmp()
return err
}
return nil
}

// renameReplace renames src over dst. On POSIX rename(2) atomically replaces and
// never fails under concurrency, so this is a single os.Rename. On Windows os.Rename
// is MoveFileEx(MOVEFILE_REPLACE_EXISTING), which returns a TRANSIENT
// ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION when the destination is momentarily
// held by a competing replace (a parallel lazy recompile from another 'brief'), an
// open reader without FILE_SHARE_DELETE, or an antivirus scan. Those are not real
// failures — the holder releases in microseconds — so a short bounded retry lets the
// replace land and keeps writeFileAtomic's concurrency guarantee true on Windows too.
// retryableRenameErr is always false off Windows, so non-Windows stays single-shot.
func renameReplace(src, dst string) error {
const attempts = 20
delay := time.Millisecond
for i := 0; ; i++ {
err := os.Rename(src, dst)
if err == nil || i == attempts-1 || !retryableRenameErr(err) {
return err
}
time.Sleep(delay)
if delay < 50*time.Millisecond {
delay *= 2
}
}
}

// printCompileWarnings raises each malformed-frontmatter warning as a loud
// MALFORMED FRONTMATTER alarm naming the consequence: the parse error discarded
// the whole frontmatter, so the note is held read-only until fixed (memento-o0a).
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/rename_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package cli

// retryableRenameErr is the non-Windows half of renameReplace's platform split: on
// POSIX rename(2) atomically replaces and never returns a transient sharing error,
// so nothing is retryable and renameReplace stays single-shot. See rename_windows.go
// for the case this exists to cover.
func retryableRenameErr(error) bool { return false }
21 changes: 21 additions & 0 deletions internal/cli/rename_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build windows

package cli

import (
"errors"
"syscall"
)

// errorSharingViolation is Win32 ERROR_SHARING_VIOLATION (32). syscall exports
// ERROR_ACCESS_DENIED but not the sharing-violation code, so it is named here rather
// than pulling in golang.org/x/sys/windows for a single constant.
const errorSharingViolation = syscall.Errno(32)

// retryableRenameErr reports whether a MoveFileEx replace failure is the transient,
// destination-momentarily-held kind renameReplace should retry rather than surface.
// ACCESS_DENIED and SHARING_VIOLATION are what a competing replace, an open reader,
// or an antivirus scan produces; any other error is a real failure and returned as-is.
func retryableRenameErr(err error) bool {
return errors.Is(err, syscall.ERROR_ACCESS_DENIED) || errors.Is(err, errorSharingViolation)
}
Loading