This is an automated email from the ASF dual-hosted git repository.

tisonkun pushed a commit to branch codex/unify-snapshot-update
in repository https://gitbox.apache.org/repos/asf/datasketches-tck.git

commit 172b35e3c1940243339957b0aaeed4c51ba324a0
Author: tison <[email protected]>
AuthorDate: Mon Oct 5 08:31:40 2026 +0800

    Unify snapshot updates and publish complete batches
---
 .github/workflows/update-snapshots.yml |  27 +--
 cmd/tck/__snapshots__/output_test.snap |  12 +-
 cmd/tck/__snapshots__/root_test.snap   |   3 +-
 cmd/tck/output.go                      |  87 +++++-----
 cmd/tck/output_test.go                 |   8 +-
 cmd/tck/snapshots.go                   | 180 +++++++-------------
 docs/serialization-snapshots.md        |  64 +++++---
 internal/snapshots/config.go           |  23 ---
 internal/snapshots/diff_test.go        |   5 +-
 internal/snapshots/publish.go          | 123 ++++++++++++++
 internal/snapshots/publish_test.go     | 106 ++++++++++++
 internal/snapshots/reconcile.go        | 290 ++++++++++++---------------------
 internal/snapshots/reconcile_test.go   | 265 ++++++++++++++++++++++++++++++
 13 files changed, 766 insertions(+), 427 deletions(-)

diff --git a/.github/workflows/update-snapshots.yml 
b/.github/workflows/update-snapshots.yml
index a00d33b..36c2a83 100644
--- a/.github/workflows/update-snapshots.yml
+++ b/.github/workflows/update-snapshots.yml
@@ -48,24 +48,19 @@ jobs:
       - name: Update snapshots
         id: update
         shell: bash
+        run: mise run tck -- snapshots update --skip-unstable-only 2>&1 | tee 
"$RUNNER_TEMP/snapshot-update.log"
+
+      - name: Summarize update
+        if: always() && steps.update.outcome != 'skipped'
         run: |
-          status=0
-          for language in cpp go java; do
-            if ! mise run tck -- snapshots update "$language" HEAD 2>&1 | tee 
-a update.log; then
-              status=1
-            fi
-          done
-          echo "status=${status}" >> "$GITHUB_OUTPUT"
           {
             echo '## Snapshot update'
             echo
             echo '```'
-            grep -E '^(Update |Summary:|Source revision:|✓|✗|Error:)' 
update.log || true
+            grep -E '^(Update |Summary:|Source revision:|✓|✗|Warning:|Error:)' 
"$RUNNER_TEMP/snapshot-update.log" || true
             echo '```'
           } >> "$GITHUB_STEP_SUMMARY"
 
-      # Sources that updated successfully are published even when another
-      # source failed, so one broken upstream does not hold back the others.
       - name: Commit updated snapshots
         run: |
           if [[ -z "$(git status --porcelain -- config.toml 
serialization_test_data)" ]]; then
@@ -78,13 +73,7 @@ jobs:
           {
             echo "chore: update snapshots to the latest upstream revisions"
             echo
-            grep -E '^(Update |Summary:|Source revision:)' update.log || true
-          } > commit-message.txt
-          git commit --quiet --file commit-message.txt
+            grep -E '^(Update |Summary:|Source revision:)' 
"$RUNNER_TEMP/snapshot-update.log" || true
+          } > "$RUNNER_TEMP/snapshot-commit-message.txt"
+          git commit --quiet --file "$RUNNER_TEMP/snapshot-commit-message.txt"
           git push origin HEAD:${{ github.ref_name }}
-
-      - name: Report failed sources
-        if: steps.update.outputs.status != '0'
-        run: |
-          grep -E '^(✗|Error:)' update.log || true
-          exit 1
diff --git a/cmd/tck/__snapshots__/output_test.snap 
b/cmd/tck/__snapshots__/output_test.snap
index 7f29055..df92ef2 100644
--- a/cmd/tck/__snapshots__/output_test.snap
+++ b/cmd/tck/__snapshots__/output_test.snap
@@ -6,8 +6,8 @@
 │ M      │ unstable  │ allow │ probabilistic.sk │ old 12 B · aaaaaaaaaaaa │
 │        │           │       │                  │ new 12 B · bbbbbbbbbbbb │
 ╰────────┴───────────┴───────┴──────────────────┴─────────────────────────╯
-Summary: 0 added · 1 modified (1 probabilistic) · 0 deleted
-! Snapshot set and stable contents are unchanged; 1 probabilistic content 
modification is allowed.
+Summary: 0 added · 1 modified (1 unstable) · 0 deleted
+! Snapshot set and stable contents are unchanged; 1 unstable content 
modification is allowed.
 
 ---
 
@@ -20,19 +20,19 @@ Summary: 0 added · 1 modified (1 probabilistic) · 0 deleted
 │        │           │       │             │ new 16 B · bbbbbbbbbbbb │
 │ D      │ stable    │ block │ deleted.sk  │ old 12 B · aaaaaaaaaaaa │
 ╰────────┴───────────┴───────┴─────────────┴─────────────────────────╯
-Summary: 1 added · 1 modified (1 probabilistic) · 1 deleted
+Summary: 1 added · 1 modified (1 unstable) · 1 deleted
 ✗ serialization_test_data/go_generated_files has 2 blocking changes.
 
 ---
 
-[TestPrintResultReportsUnwrittenUpdate - 1]
+[TestPrintUpdateResultReportsSkippedChanges - 1]
 ╭────────┬───────────┬───────┬──────────────────┬─────────────────────────╮
 │ CHANGE │ STABILITY │ CHECK │ SNAPSHOT         │ CONTENT (SIZE · SHA256) │
 ├────────┼───────────┼───────┼──────────────────┼─────────────────────────┤
 │ M      │ unstable  │ allow │ probabilistic.sk │ old 12 B · aaaaaaaaaaaa │
 │        │           │       │                  │ new 12 B · bbbbbbbbbbbb │
 ╰────────┴───────────┴───────┴──────────────────┴─────────────────────────╯
-Summary: 0 added · 1 modified (1 probabilistic) · 0 deleted
-✓ snapshots already matches its source revision; 1 probabilistic content 
modification was not written.
+Summary: 0 added · 1 modified (1 unstable) · 0 deleted
+✓ Skipped snapshots: source revision unchanged; 1 unstable content 
modification was not written.
 
 ---
diff --git a/cmd/tck/__snapshots__/root_test.snap 
b/cmd/tck/__snapshots__/root_test.snap
index 7442f13..b0127ff 100644
--- a/cmd/tck/__snapshots__/root_test.snap
+++ b/cmd/tck/__snapshots__/root_test.snap
@@ -8,8 +8,7 @@ Usage:
 
 Available Commands:
   check       Check the snapshot set and stable snapshot contents
-  sync        Synchronize snapshots from config.toml
-  update      Adopt one upstream revision and update its snapshots
+  update      Update snapshots and source revisions together
 
 Flags:
   -h, --help   help for snapshots
diff --git a/cmd/tck/output.go b/cmd/tck/output.go
index 4776f46..53cb32d 100644
--- a/cmd/tck/output.go
+++ b/cmd/tck/output.go
@@ -29,20 +29,56 @@ import (
        "github.com/jedib0t/go-pretty/v6/text"
 )
 
-func printResult(output io.Writer, root string, mode snapshots.Mode, result 
snapshots.Result) error {
+func printCheckResult(output io.Writer, root string, result snapshots.Result) 
error {
+       if err := printChanges(output, result); err != nil {
+               return err
+       }
        target := displayPath(root, result.Target)
-       if len(result.Changes) == 0 {
-               if result.RevisionChanged() {
-                       if err := printRevisionChange(output, result); err != 
nil {
-                               return err
-                       }
-                       _, err := fmt.Fprintf(output, "✓ Updated %s.\n", target)
-                       return err
-               }
+       switch {
+       case len(result.Changes) == 0:
                _, err := fmt.Fprintf(output, "✓ %s is up to date.\n", target)
                return err
+       case result.HasBlockingChanges():
+               count := result.BlockingChangeCount()
+               _, err := fmt.Fprintf(output, "✗ %s has %d blocking %s.\n", 
target, count, plural(count, "change", "changes"))
+               return err
+       default:
+               count := len(result.Changes)
+               _, err := fmt.Fprintf(output, "! Snapshot set and stable 
contents are unchanged; %d unstable content %s allowed.\n",
+                       count, plural(count, "modification is", "modifications 
are"))
+               return err
        }
+}
 
+func printUpdateResult(output io.Writer, root string, result snapshots.Result, 
skipUnstableOnly bool) error {
+       if err := printChanges(output, result); err != nil {
+               return err
+       }
+       target := displayPath(root, result.Target)
+       switch {
+       case skipUnstableOnly && result.UnstableOnly():
+               _, err := fmt.Fprintf(output, "✓ Skipped %s: source revision 
unchanged; %d unstable content %s not written.\n",
+                       target, len(result.Changes), 
plural(len(result.Changes), "modification was", "modifications were"))
+               return err
+       case result.RevisionChanged() && len(result.Changes) == 0:
+               _, err := fmt.Fprintf(output, "✓ Updated source revision for 
%s; snapshot contents are unchanged.\n", target)
+               return err
+       case len(result.Changes) == 0:
+               _, err := fmt.Fprintf(output, "✓ %s is up to date.\n", target)
+               return err
+       default:
+               _, err := fmt.Fprintf(output, "✓ Updated %s.\n", target)
+               return err
+       }
+}
+
+func printChanges(output io.Writer, result snapshots.Result) error {
+       if len(result.Changes) == 0 {
+               if result.RevisionChanged() {
+                       return printRevisionChange(output, result)
+               }
+               return nil
+       }
        counts := make(map[snapshots.ChangeStatus]int)
        unstableModified := 0
        changeTable := table.NewWriter()
@@ -70,7 +106,7 @@ func printResult(output io.Writer, root string, mode 
snapshots.Mode, result snap
        }
        if _, err := fmt.Fprintf(
                output,
-               "Summary: %d added · %d modified (%d probabilistic) · %d 
deleted\n",
+               "Summary: %d added · %d modified (%d unstable) · %d deleted\n",
                counts[snapshots.ChangeAdded],
                counts[snapshots.ChangeModified],
                unstableModified,
@@ -83,36 +119,7 @@ func printResult(output io.Writer, root string, mode 
snapshots.Mode, result snap
                        return err
                }
        }
-
-       switch {
-       case (mode == snapshots.ModeUpdate || mode == snapshots.ModeSync) && 
!result.Written:
-               _, err := fmt.Fprintf(
-                       output,
-                       "✓ %s already matches its source revision; %d 
probabilistic content %s not written.\n",
-                       target,
-                       unstableModified,
-                       plural(unstableModified, "modification was", 
"modifications were"),
-               )
-               return err
-       case mode == snapshots.ModeUpdate:
-               _, err := fmt.Fprintf(output, "✓ Updated %s.\n", target)
-               return err
-       case mode == snapshots.ModeSync:
-               _, err := fmt.Fprintf(output, "✓ Synced %s.\n", target)
-               return err
-       case result.HasBlockingChanges():
-               count := result.BlockingChangeCount()
-               _, err := fmt.Fprintf(output, "✗ %s has %d blocking %s.\n", 
target, count, plural(count, "change", "changes"))
-               return err
-       default:
-               _, err := fmt.Fprintf(
-                       output,
-                       "! Snapshot set and stable contents are unchanged; %d 
probabilistic content %s allowed.\n",
-                       unstableModified,
-                       plural(unstableModified, "modification is", 
"modifications are"),
-               )
-               return err
-       }
+       return nil
 }
 
 func printRevisionChange(output io.Writer, result snapshots.Result) error {
diff --git a/cmd/tck/output_test.go b/cmd/tck/output_test.go
index 15f2d2c..3c529d4 100644
--- a/cmd/tck/output_test.go
+++ b/cmd/tck/output_test.go
@@ -52,7 +52,7 @@ func TestPrintResultIncludesDetailedFileChanges(t *testing.T) 
{
        }
 
        var output bytes.Buffer
-       require.NoError(t, printResult(&output, root, snapshots.ModeCheck, 
result))
+       require.NoError(t, printCheckResult(&output, root, result))
        snaps.WithConfig(snaps.Raw()).MatchSnapshot(t, output.String())
 }
 
@@ -70,11 +70,11 @@ func TestPrintResultAllowsOnlyUnstableModifications(t 
*testing.T) {
        }
 
        var output bytes.Buffer
-       require.NoError(t, printResult(&output, ".", snapshots.ModeCheck, 
result))
+       require.NoError(t, printCheckResult(&output, ".", result))
        snaps.WithConfig(snaps.Raw()).MatchSnapshot(t, output.String())
 }
 
-func TestPrintResultReportsUnwrittenUpdate(t *testing.T) {
+func TestPrintUpdateResultReportsSkippedChanges(t *testing.T) {
        t.Parallel()
 
        before := testMetadata(12, 0xaa)
@@ -90,7 +90,7 @@ func TestPrintResultReportsUnwrittenUpdate(t *testing.T) {
        }
 
        var output bytes.Buffer
-       require.NoError(t, printResult(&output, ".", snapshots.ModeUpdate, 
result))
+       require.NoError(t, printUpdateResult(&output, ".", result, true))
        snaps.WithConfig(snaps.Raw()).MatchSnapshot(t, output.String())
 }
 
diff --git a/cmd/tck/snapshots.go b/cmd/tck/snapshots.go
index 19c8aca..ab7db11 100644
--- a/cmd/tck/snapshots.go
+++ b/cmd/tck/snapshots.go
@@ -24,6 +24,7 @@ import (
        "os"
        "os/exec"
        "path/filepath"
+       "slices"
        "strings"
 
        "github.com/apache/datasketches-tck/internal/snapshots"
@@ -43,152 +44,95 @@ func newSnapshotsCommand() *cobra.Command {
        }
        command.AddCommand(
                newSnapshotCheckCommand(),
-               newSnapshotSyncCommand(),
                newSnapshotUpdateCommand(),
        )
        return command
 }
 
 func newSnapshotCheckCommand() *cobra.Command {
-       validLanguages := append(snapshots.Languages(), "all")
        return &cobra.Command{
                Use:       "check <cpp|go|java|all>",
-               Short:     reconcileDescription(snapshots.ModeCheck),
-               Long:      reconcileDetails(snapshots.ModeCheck),
+               Short:     "Check the snapshot set and stable snapshot 
contents",
+               Long:      "Regenerate snapshots at the configured commits 
without writing files. Additions, deletions, and stable content changes fail 
the check; unstable content changes are allowed.",
                Args:      cobra.MatchAll(cobra.ExactArgs(1), 
cobra.OnlyValidArgs),
-               ValidArgs: validLanguages,
+               ValidArgs: append(snapshots.Languages(), "all"),
                RunE: func(command *cobra.Command, args []string) error {
+                       root, err := repositoryRoot(command.Context())
+                       if err != nil {
+                               return err
+                       }
                        languages := []string{args[0]}
                        if args[0] == "all" {
                                languages = snapshots.Languages()
                        }
-                       return reconcileSnapshots(command, snapshots.ModeCheck, 
languages, "")
-               },
-       }
-}
-
-func newSnapshotSyncCommand() *cobra.Command {
-       return &cobra.Command{
-               Use:   "sync",
-               Short: reconcileDescription(snapshots.ModeSync),
-               Long:  reconcileDetails(snapshots.ModeSync),
-               Args:  cobra.NoArgs,
-               RunE: func(command *cobra.Command, _ []string) error {
-                       return reconcileSnapshots(command, snapshots.ModeSync, 
snapshots.Languages(), "")
+                       outOfDate := false
+                       for _, language := range languages {
+                               result, err := 
snapshots.Check(command.Context(), root, language, command.OutOrStdout(), 
command.ErrOrStderr())
+                               if err != nil {
+                                       return err
+                               }
+                               if _, err := fmt.Fprintf(command.OutOrStdout(), 
"\nCheck %s snapshots\n", languageHeading(language)); err != nil {
+                                       return err
+                               }
+                               if err := 
printCheckResult(command.OutOrStdout(), root, result); err != nil {
+                                       return err
+                               }
+                               outOfDate = outOfDate || 
result.HasBlockingChanges()
+                       }
+                       if outOfDate {
+                               return errSnapshotsOutOfDate
+                       }
+                       return nil
                },
        }
 }
 
 func newSnapshotUpdateCommand() *cobra.Command {
-       return &cobra.Command{
-               Use:   "update <cpp|go|java> <revision>",
-               Short: reconcileDescription(snapshots.ModeUpdate),
-               Long:  reconcileDetails(snapshots.ModeUpdate),
+       var skipUnstableOnly bool
+       command := &cobra.Command{
+               Use:   "update [cpp|go|java] [revision]",
+               Short: "Update snapshots and source revisions together",
+               Long:  "Update all sources to their remote HEADs, or select one 
language and optionally a branch, tag, or commit. All selected sources are 
generated before any snapshots or pins are published.",
                Args: func(command *cobra.Command, args []string) error {
-                       if err := cobra.ExactArgs(2)(command, args); err != nil 
{
+                       if err := cobra.RangeArgs(0, 2)(command, args); err != 
nil {
                                return err
                        }
-                       for _, language := range snapshots.Languages() {
-                               if args[0] == language {
-                                       return nil
-                               }
+                       if len(args) > 0 && 
!slices.Contains(snapshots.Languages(), args[0]) {
+                               return fmt.Errorf("unsupported snapshot 
language %q", args[0])
                        }
-                       return fmt.Errorf("unsupported snapshot language %q", 
args[0])
+                       return nil
                },
                ValidArgs: snapshots.Languages(),
                RunE: func(command *cobra.Command, args []string) error {
-                       return reconcileSnapshots(command, 
snapshots.ModeUpdate, []string{args[0]}, args[1])
-               },
-       }
-}
-
-func reconcileSnapshots(
-       command *cobra.Command,
-       mode snapshots.Mode,
-       languages []string,
-       requestedRevision string,
-) error {
-       root, err := repositoryRoot(command.Context())
-       if err != nil {
-               return err
-       }
-
-       outOfDate := false
-       for index, language := range languages {
-               if index > 0 {
-                       if _, err := fmt.Fprintln(command.OutOrStdout()); err 
!= nil {
-                               return fmt.Errorf("separate snapshot results: 
%w", err)
+                       root, err := repositoryRoot(command.Context())
+                       if err != nil {
+                               return err
                        }
-               }
-               if _, err := fmt.Fprintf(
-                       command.OutOrStdout(),
-                       "%s %s snapshots\n",
-                       modeHeading(mode),
-                       languageHeading(language),
-               ); err != nil {
-                       return fmt.Errorf("print snapshot heading: %w", err)
-               }
-
-               result, err := snapshots.Reconcile(
-                       command.Context(),
-                       root,
-                       language,
-                       mode,
-                       requestedRevision,
-                       command.OutOrStdout(),
-                       command.ErrOrStderr(),
-               )
-               if err != nil {
-                       return err
-               }
-               if err := printResult(command.OutOrStdout(), root, mode, 
result); err != nil {
-                       return fmt.Errorf("print snapshot result: %w", err)
-               }
-               outOfDate = outOfDate || (mode == snapshots.ModeCheck && 
result.HasBlockingChanges())
-       }
-       if outOfDate {
-               return errSnapshotsOutOfDate
-       }
-       return nil
-}
-
-func reconcileDescription(mode snapshots.Mode) string {
-       switch mode {
-       case snapshots.ModeCheck:
-               return "Check the snapshot set and stable snapshot contents"
-       case snapshots.ModeSync:
-               return "Synchronize snapshots from config.toml"
-       case snapshots.ModeUpdate:
-               return "Adopt one upstream revision and update its snapshots"
-       default:
-               panic(fmt.Sprintf("unsupported snapshot mode %q", mode))
-       }
-}
-
-func reconcileDetails(mode snapshots.Mode) string {
-       switch mode {
-       case snapshots.ModeCheck:
-               return "Generate snapshots and verify that the file set and 
stable contents match. Content changes to existing probabilistic snapshots are 
reported but do not fail the check."
-       case snapshots.ModeSync:
-               return "Regenerate all snapshot directories from the 
repositories and commits in config.toml without changing the config."
-       case snapshots.ModeUpdate:
-               return "Resolve an upstream commit, branch, or tag, record its 
exact commit ID in config.toml, and regenerate that source's snapshots. 
Snapshots are left untouched when the revision is unchanged and only 
probabilistic snapshots differ."
-       default:
-               panic(fmt.Sprintf("unsupported snapshot mode %q", mode))
-       }
-}
-
-func modeHeading(mode snapshots.Mode) string {
-       switch mode {
-       case snapshots.ModeCheck:
-               return "Check"
-       case snapshots.ModeSync:
-               return "Sync"
-       case snapshots.ModeUpdate:
-               return "Update"
-       default:
-               panic(fmt.Sprintf("unsupported snapshot mode %q", mode))
+                       language, revision := "", ""
+                       if len(args) > 0 {
+                               language = args[0]
+                       }
+                       if len(args) > 1 {
+                               revision = args[1]
+                       }
+                       results, err := snapshots.Update(command.Context(), 
root, language, revision, skipUnstableOnly, command.OutOrStdout(), 
command.ErrOrStderr())
+                       if err != nil {
+                               return err
+                       }
+                       for _, result := range results {
+                               if _, err := fmt.Fprintf(command.OutOrStdout(), 
"\nUpdate %s\n", displayPath(root, result.Target)); err != nil {
+                                       return err
+                               }
+                               if err := 
printUpdateResult(command.OutOrStdout(), root, result, skipUnstableOnly); err 
!= nil {
+                                       return err
+                               }
+                       }
+                       return nil
+               },
        }
+       command.Flags().BoolVar(&skipUnstableOnly, "skip-unstable-only", false,
+               "Skip a source when its revision is unchanged and only existing 
unstable snapshots differ")
+       return command
 }
 
 func languageHeading(language string) string {
diff --git a/docs/serialization-snapshots.md b/docs/serialization-snapshots.md
index 3b4eda9..4db06ae 100644
--- a/docs/serialization-snapshots.md
+++ b/docs/serialization-snapshots.md
@@ -31,32 +31,52 @@ repository = "https://github.com/apache/datasketches-go.git";
 commit = "730c0ca31e00b8becf8b70591ae8ca73954912d0"
 ```
 
-## Update to the latest snapshots
+## Update snapshots
 
-With the [toolchain installed](#set-up-the-toolchain), use `HEAD` to update 
each source to its default branch:
+With the [toolchain installed](#set-up-the-toolchain), update every available 
source to its remote default branch:
 
 ```shell
-mise run tck -- snapshots update cpp HEAD
-mise run tck -- snapshots update go HEAD
-mise run tck -- snapshots update java HEAD
+mise run tck -- snapshots update
 ```
 
-For each source, the command resolves the default branch to an exact commit 
ID, writes it to `config.toml`, and regenerates 
`serialization_test_data/<language>_generated_files`. Generation happens before 
either file set is changed, so a source whose generation fails is left 
untouched.
+Update is the only command that writes snapshots. It accepts an optional 
language and, after that, an optional revision:
 
-When a source's commit has not changed and only probabilistic snapshots 
differ, its snapshots are left as they are, so repeated updates do not add 
binary churn to the history.
+| Command | Sources and revisions |
+| --- | --- |
+| `snapshots update` | All available languages at their remote `HEAD` |
+| `snapshots update go` | Go at its remote `HEAD` |
+| `snapshots update go HEAD` | The same explicit selection |
+| `snapshots update go v0.2.0` | Go at the specified tag; branches and commit 
IDs also work |
 
-The [Update snapshots](#github-actions) workflow runs these commands weekly 
and commits the result to `main`. It continues with the other languages if one 
fails, then reports the failure after committing successful updates.
+A revision can only be supplied after a language. To regenerate or repair 
snapshots at an existing pin, pass the commit recorded in `config.toml` 
explicitly. Updating to an older revision follows the same process as updating 
to a newer one. If the upstream build or output layout changed, update the 
corresponding adapter in `internal/snapshots/<language>.go` before running the 
command.
 
-## Update one source
+Every selected source is generated in a temporary workspace and compared 
before any repository files are replaced. A generation or staging failure 
aborts the whole batch. Successful updates publish the selected snapshot 
directories and their exact commits in `config.toml` together. A changed 
revision with byte-identical snapshots only changes the pin, after generation 
has verified those contents.
 
-Update a single source to its default branch, or to a specific branch, tag, or 
commit:
+### Skip unstable-only changes
+
+`stable` snapshots have deterministic contents. `unstable` snapshots are known 
to vary between generations, even at the same source commit. By default, update 
adopts all generated differences, including unstable contents. Add 
`--skip-unstable-only` to any update invocation to avoid rewriting a source 
whose commit is unchanged and whose only differences are modifications to 
existing unstable files:
 
 ```shell
-mise run tck -- snapshots update go HEAD
-mise run tck -- snapshots update go v0.2.0
+mise run tck -- snapshots update --skip-unstable-only
+mise run tck -- snapshots update go --skip-unstable-only
+mise run tck -- snapshots update go v0.2.0 --skip-unstable-only
 ```
 
-Pinning an older revision is useful for reproducing a release or for holding a 
source back while an upstream problem is fixed. If the upstream build or output 
layout changed, update the corresponding adapter in 
`internal/snapshots/<language>.go` before running the command.
+The option applies independently to each selected language. It skips that 
source's entire update; it never filters individual files from an adopted 
snapshot set. A revision change, any added or deleted file, or any stable 
content change causes the complete generated snapshot set to be adopted, 
including its unstable files. The report identifies skipped sources and shows 
observed differences even when they were not written.
+
+| Source revision | Snapshot differences | Default | With 
`--skip-unstable-only` |
+| --- | --- | --- | --- |
+| Unchanged | None | No changes | No changes |
+| Unchanged | Only existing unstable contents | Replace snapshots | Keep the 
existing snapshots |
+| Unchanged | Additions, deletions, or stable contents | Replace snapshots | 
Replace snapshots |
+| Changed | None | Update the pin | Update the pin |
+| Changed | Any | Update the pin and snapshots | Update the pin and snapshots |
+
+### Publication and failures
+
+The publisher stages the complete batch on the repository filesystem and 
retains backups until all replacements succeed. An installation error rolls 
back earlier replacements, including those for other languages. If rollback 
itself fails, the command reports the recovery directory and keeps its backups. 
Failure to remove backups after a successful publication is a warning and does 
not undo the published pins or snapshots.
+
+This is rollback protection for ordinary errors, not a crash-atomic filesystem 
transaction or synchronization for concurrent local writers. Do not run 
multiple updates against the same checkout. The command refuses to overwrite 
config edits detected after generation. In CI, an update failure prevents the 
commit step; a successful batch is published in one Git commit and a normal 
push. A rejected push leaves the remote unchanged; a push publishes the 
complete commit, never part of the batch.
 
 ## Review an update
 
@@ -90,16 +110,6 @@ Check mode does not modify the repository. It fails for 
added or deleted files a
 
 This command answers whether the repository matches its pin; it does not 
determine whether the pin is the latest upstream commit. Use update mode for 
that.
 
-## Synchronize configured snapshots
-
-Regenerate every snapshot directory from the repositories and commits 
currently recorded in `config.toml`:
-
-```shell
-mise run tck -- snapshots sync
-```
-
-Sync may change the committed snapshot directories, but it never changes 
`config.toml`.
-
 ## Set up the toolchain
 
 Install [mise](https://mise.jdx.dev/), then install the pinned toolchain and 
inspect the available snapshot commands:
@@ -111,7 +121,7 @@ mise run tck -- snapshots --help
 
 Mise supplies Go, CMake and CTest, Java, and Maven. Git is required for every 
source language, a C++ compiler is required for C++, and Make is required for 
Go.
 
-Check mode accepts `cpp`, `go`, `java`, or `all`. Synchronization takes no 
arguments. Update accepts one of `cpp`, `go`, or `java`, followed by a required 
revision.
+Check accepts `cpp`, `go`, `java`, or `all` and is always read-only. Update 
defaults to all languages at `HEAD`; specifying a language limits the update to 
that source, and a second argument selects its revision. The former `snapshots 
sync` command is replaced by `snapshots update <language> <recorded-commit>`.
 
 ## Use the corpus from an implementation
 
@@ -127,16 +137,16 @@ Some upstream generators contain randomness, so 
byte-for-byte reproduction is no
 
 Only modifications to existing probabilistic files are allowed in check mode. 
Additions and deletions always block the check because they alter the 
compatibility corpus, and modifications to deterministic files block because 
they indicate either a compatibility change or a non-reproducible generator.
 
-The probabilistic classification only controls byte-level comparison in this 
repository. Upstream generators remain responsible for constructing valid 
sketches, and consumers remain responsible for algorithm-appropriate assertions.
+The stable/unstable classification controls check results and the optional 
unstable-only update policy. It does not prove that an individual difference is 
random or that an old snapshot is valid for a new source revision. Upstream 
generators remain responsible for constructing valid sketches, and consumers 
remain responsible for algorithm-appropriate assertions.
 
 ## GitHub Actions
 
 `.github/workflows/check.yml` runs `mise run check` for pull requests and 
pushes to `main`. It validates the Go implementation of the TCK tooling, but it 
does not run the upstream snapshot generators or modify committed snapshots.
 
-`.github/workflows/update-snapshots.yml` runs `snapshots update <language> 
HEAD` for `cpp`, `go`, and `java` every Monday and on manual dispatch, and 
commits any updated pins and snapshots directly to `main`. It is not a required 
check. The job summary lists each source's revision change and change counts. 
If a source fails to generate, the other sources are still committed and the 
run fails so that the broken upstream is noticed.
+`.github/workflows/update-snapshots.yml` runs `snapshots update 
--skip-unstable-only` every Monday and on manual dispatch. It is not a required 
check. The job summary lists each source's revision change, change counts, and 
skipped updates. The workflow commits and pushes the batch only after the 
command succeeds; any failed source prevents publication of the whole batch.
 
 ## Implementation notes
 
-For each requested language, the `tck` command reads the repository and commit 
from `config.toml`, checks out that revision in a temporary workspace, and 
invokes the source-specific adapter in `internal/snapshots/<language>.go`. It 
then compares the generated output with 
`serialization_test_data/<language>_generated_files`.
+For each requested language, the `tck` command reads the source repository and 
existing pin from `config.toml`. Check generates at that pin; update resolves 
the requested revision, defaulting to remote `HEAD`. Both invoke the 
source-specific adapter in `internal/snapshots/<language>.go` in a temporary 
workspace and compare its output with 
`serialization_test_data/<language>_generated_files`. Update stages and 
publishes the selected directories and revised config as one batch.
 
 The command-line interface and change report live in `cmd/tck`. Reconciliation 
and file comparison live in `internal/snapshots`, where `stability.go` 
classifies deterministic and known probabilistic outputs.
diff --git a/internal/snapshots/config.go b/internal/snapshots/config.go
index 2746df4..78bb0bd 100644
--- a/internal/snapshots/config.go
+++ b/internal/snapshots/config.go
@@ -141,26 +141,3 @@ func encodeConfig(config repositoryConfig) ([]byte, error) 
{
                config.Snapshot.Java.Commit,
        )), nil
 }
-
-func replaceConfigFile(repositoryRoot string, content []byte) error {
-       target := filepath.Join(repositoryRoot, configFilename)
-       transaction, err := os.MkdirTemp(repositoryRoot, ".tck-config-")
-       if err != nil {
-               return fmt.Errorf("create config update transaction: %w", err)
-       }
-       defer func() { _ = os.RemoveAll(transaction) }()
-
-       next := filepath.Join(transaction, "next")
-       if err := os.WriteFile(next, content, 0o644); err != nil {
-               return fmt.Errorf("stage TCK config: %w", err)
-       }
-       previous := filepath.Join(transaction, "previous")
-       if err := os.Rename(target, previous); err != nil {
-               return fmt.Errorf("preserve current TCK config: %w", err)
-       }
-       if err := os.Rename(next, target); err != nil {
-               _ = os.Rename(previous, target)
-               return fmt.Errorf("install TCK config: %w", err)
-       }
-       return nil
-}
diff --git a/internal/snapshots/diff_test.go b/internal/snapshots/diff_test.go
index be011c9..0530e76 100644
--- a/internal/snapshots/diff_test.go
+++ b/internal/snapshots/diff_test.go
@@ -19,6 +19,7 @@ package snapshots
 
 import (
        "crypto/sha256"
+       "io"
        "os"
        "path/filepath"
        "testing"
@@ -83,7 +84,7 @@ func TestChangeIsBlocking(t *testing.T) {
        }
 }
 
-func TestReplaceDirectoryReplacesTheExactFileSet(t *testing.T) {
+func TestPublishReplacesTheExactFileSet(t *testing.T) {
        t.Parallel()
 
        root := t.TempDir()
@@ -94,7 +95,7 @@ func TestReplaceDirectoryReplacesTheExactFileSet(t 
*testing.T) {
        writeTestFile(t, filepath.Join(generated, "modified.sk"), "new")
        writeTestFile(t, filepath.Join(generated, "nested", "added.sk"), 
"added")
 
-       require.NoError(t, replaceDirectory(target, generated))
+       require.NoError(t, publish(root, []replacement{{target: target, source: 
generated}}, io.Discard))
        requireTestFile(t, filepath.Join(target, "modified.sk"), "new")
        requireTestFile(t, filepath.Join(target, "nested", "added.sk"), "added")
        require.NoFileExists(t, filepath.Join(target, "deleted.sk"))
diff --git a/internal/snapshots/publish.go b/internal/snapshots/publish.go
new file mode 100644
index 0000000..70fad77
--- /dev/null
+++ b/internal/snapshots/publish.go
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package snapshots
+
+import (
+       "errors"
+       "fmt"
+       "io"
+       "os"
+       "path/filepath"
+       "strconv"
+)
+
+type replacement struct {
+       target string
+       source string
+}
+
+// publish stages the complete batch on the destination filesystem, then keeps
+// backups until every replacement succeeds. Rollback failures preserve those
+// backups for recovery. Cleanup after success must never trigger a rollback.
+// This protects ordinary error paths, not concurrent readers or process 
crashes.
+func publish(root string, replacements []replacement, stderr io.Writer) (err 
error) {
+       if len(replacements) == 0 {
+               return nil
+       }
+       transaction, err := os.MkdirTemp(root, ".tck-update-")
+       if err != nil {
+               return fmt.Errorf("create update transaction: %w", err)
+       }
+       var undo []func() error
+       defer func() {
+               if err != nil {
+                       var rollbackErr error
+                       for index := len(undo) - 1; index >= 0; index-- {
+                               rollbackErr = errors.Join(rollbackErr, 
undo[index]())
+                       }
+                       if rollbackErr != nil {
+                               err = fmt.Errorf("%w; rollback failed: %v; 
recovery files remain at %s", err, rollbackErr, transaction)
+                               return
+                       }
+               }
+               if cleanupErr := os.RemoveAll(transaction); cleanupErr != nil {
+                       _, _ = fmt.Fprintf(stderr, "Warning: could not remove 
update backups at %s: %v\n", transaction, cleanupErr)
+               }
+       }()
+
+       for index, replacement := range replacements {
+               next := filepath.Join(transaction, "next-"+strconv.Itoa(index))
+               info, err := os.Stat(replacement.source)
+               if err != nil {
+                       return err
+               }
+               if current, err := os.Lstat(replacement.target); err == nil {
+                       if current.Mode()&os.ModeSymlink != 0 || 
current.IsDir() != info.IsDir() || (!current.IsDir() && 
!current.Mode().IsRegular()) {
+                               return fmt.Errorf("cannot replace %s: 
unexpected file type", replacement.target)
+                       }
+               } else if !os.IsNotExist(err) {
+                       return err
+               }
+               if info.IsDir() {
+                       err = copyDirectory(replacement.source, next)
+               } else {
+                       err = copyFile(replacement.source, next)
+               }
+               if err != nil {
+                       return fmt.Errorf("stage %s: %w", replacement.target, 
err)
+               }
+       }
+       for index, replacement := range replacements {
+               target := replacement.target
+               if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+                       return err
+               }
+               previous := filepath.Join(transaction, 
"previous-"+strconv.Itoa(index))
+               if err := os.Rename(target, previous); err == nil {
+                       undo = append(undo, func() error { return 
os.Rename(previous, target) })
+               } else if !os.IsNotExist(err) {
+                       return fmt.Errorf("preserve %s: %w", target, err)
+               }
+               next := filepath.Join(transaction, "next-"+strconv.Itoa(index))
+               if err := os.Rename(next, target); err != nil {
+                       return fmt.Errorf("install %s: %w", target, err)
+               }
+               undo = append(undo, func() error { return os.RemoveAll(target) 
})
+       }
+       return nil
+}
+
+func copyDirectory(source, destination string) error {
+       files, err := regularFiles(source)
+       if err != nil {
+               return err
+       }
+       if err := os.MkdirAll(destination, 0o755); err != nil {
+               return err
+       }
+       for relative, file := range files {
+               output := filepath.Join(destination, 
filepath.FromSlash(relative))
+               if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
+                       return err
+               }
+               if err := copyFile(file.filename, output); err != nil {
+                       return err
+               }
+       }
+       return nil
+}
diff --git a/internal/snapshots/publish_test.go 
b/internal/snapshots/publish_test.go
new file mode 100644
index 0000000..feaee0b
--- /dev/null
+++ b/internal/snapshots/publish_test.go
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package snapshots
+
+import (
+       "bytes"
+       "io"
+       "os"
+       "path/filepath"
+       "runtime"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestPublishStagingFailureDoesNotChangeTargets(t *testing.T) {
+       root := t.TempDir()
+       target, next := filepath.Join(root, "config.toml"), filepath.Join(root, 
"next")
+       writeTestFile(t, target, "old")
+       writeTestFile(t, next, "new")
+       err := publish(root, []replacement{
+               {target: target, source: next},
+               {target: filepath.Join(root, "snapshots"), source: 
filepath.Join(root, "missing")},
+       }, io.Discard)
+       require.Error(t, err)
+       requireTestFile(t, target, "old")
+       require.NoDirExists(t, filepath.Join(root, "snapshots"))
+}
+
+func TestPublishRollsBackEarlierFilesAndDirectories(t *testing.T) {
+       root := t.TempDir()
+       nextFile, nextDir := filepath.Join(root, "next-config"), 
filepath.Join(root, "next-snapshots")
+       writeTestFile(t, nextFile, "new config")
+       writeTestFile(t, filepath.Join(nextDir, "new.sk"), "new")
+       config, snapshots := filepath.Join(root, "config.toml"), 
filepath.Join(root, "snapshots")
+       absent, locked := filepath.Join(root, "absent"), filepath.Join(root, 
"locked", "target")
+       writeTestFile(t, config, "old config")
+       writeTestFile(t, filepath.Join(snapshots, "old.sk"), "old")
+       writeTestFile(t, locked, "old locked file")
+       protectDirectory(t, filepath.Dir(locked))
+       err := publish(root, []replacement{
+               {target: config, source: nextFile},
+               {target: snapshots, source: nextDir},
+               {target: absent, source: nextDir},
+               {target: locked, source: nextFile},
+       }, io.Discard)
+       require.ErrorContains(t, err, "preserve")
+       requireTestFile(t, config, "old config")
+       requireTestFile(t, filepath.Join(snapshots, "old.sk"), "old")
+       require.NoFileExists(t, filepath.Join(snapshots, "new.sk"))
+       require.NoDirExists(t, absent)
+       requireTestFile(t, locked, "old locked file")
+}
+
+func TestPublishCleanupFailureKeepsNewConfigAndSnapshots(t *testing.T) {
+       root := t.TempDir()
+       target, config := filepath.Join(root, "snapshots"), filepath.Join(root, 
"config.toml")
+       nextDir, nextFile := filepath.Join(root, "next-snapshots"), 
filepath.Join(root, "next-config")
+       writeTestFile(t, filepath.Join(target, "protected", "old.sk"), "old")
+       writeTestFile(t, config, "old revision")
+       writeTestFile(t, filepath.Join(nextDir, "new.sk"), "new")
+       writeTestFile(t, nextFile, "new revision")
+       protectDirectory(t, filepath.Join(target, "protected"))
+       // The protected directory moves into the transaction backup.
+       t.Cleanup(func() {
+               _ = filepath.Walk(root, func(path string, info os.FileInfo, err 
error) error {
+                       if err == nil && info.IsDir() {
+                               _ = os.Chmod(path, 0o755)
+                       }
+                       return nil
+               })
+       })
+       var warnings bytes.Buffer
+       require.NoError(t, publish(root, []replacement{
+               {target: target, source: nextDir},
+               {target: config, source: nextFile},
+       }, &warnings))
+       require.Contains(t, warnings.String(), "Warning: could not remove 
update backups")
+       requireTestFile(t, config, "new revision")
+       requireTestFile(t, filepath.Join(target, "new.sk"), "new")
+       require.NoDirExists(t, filepath.Join(target, "protected"))
+}
+
+func protectDirectory(t *testing.T, directory string) {
+       t.Helper()
+       if runtime.GOOS == "windows" || os.Geteuid() == 0 {
+               t.Skip("requires Unix permission enforcement")
+       }
+       require.NoError(t, os.Chmod(directory, 0o555))
+       t.Cleanup(func() { _ = os.Chmod(directory, 0o755) })
+}
diff --git a/internal/snapshots/reconcile.go b/internal/snapshots/reconcile.go
index 92b7316..4de015c 100644
--- a/internal/snapshots/reconcile.go
+++ b/internal/snapshots/reconcile.go
@@ -18,8 +18,8 @@
 package snapshots
 
 import (
+       "bytes"
        "context"
-       "errors"
        "fmt"
        "io"
        "os"
@@ -27,21 +27,13 @@ import (
        "path/filepath"
 )
 
-type Mode string
-
-const (
-       ModeCheck  Mode = "check"
-       ModeSync   Mode = "sync"
-       ModeUpdate Mode = "update"
-)
-
+// Result describes the generated revision and its differences from the 
existing
+// snapshot directory, including differences skipped by the update policy.
 type Result struct {
        Target           string
        PreviousRevision string
        Revision         string
        Changes          []Change
-       // Written reports whether the snapshot directory was replaced.
-       Written bool
 }
 
 func (result Result) RevisionChanged() bool {
@@ -62,214 +54,140 @@ func (result Result) BlockingChangeCount() int {
        return count
 }
 
-func Reconcile(
-       ctx context.Context,
-       repositoryRoot, language string,
-       mode Mode,
-       requestedRevision string,
-       stdout, stderr io.Writer,
-) (Result, error) {
-       if mode != ModeCheck && mode != ModeSync && mode != ModeUpdate {
-               return Result{}, fmt.Errorf("unsupported reconciliation mode 
%q", mode)
-       }
-       generator, found := generators[language]
-       if !found {
-               return Result{}, fmt.Errorf("unsupported snapshot language %q", 
language)
-       }
-       if mode == ModeUpdate && requestedRevision == "" {
-               return Result{}, fmt.Errorf("a source revision is required in 
update mode")
-       }
-       if mode != ModeUpdate && requestedRevision != "" {
-               return Result{}, fmt.Errorf("a source revision can only be 
selected in update mode")
-       }
-       config, originalConfig, err := loadConfig(repositoryRoot)
+// UnstableOnly reports whether the only differences are content changes to
+// existing unstable snapshots. A revision change always requires an update.
+func (result Result) UnstableOnly() bool {
+       return !result.RevisionChanged() && len(result.Changes) > 0 && 
!result.HasBlockingChanges()
+}
+
+// Check regenerates a source at its configured commit without writing 
repository
+// files. Snapshot differences are returned in Result rather than as errors.
+func Check(ctx context.Context, root, language string, stdout, stderr 
io.Writer) (Result, error) {
+       config, _, err := loadConfig(root)
        if err != nil {
                return Result{}, err
        }
        source, found := config.source(language)
        if !found {
-               return Result{}, fmt.Errorf("snapshot source for unsupported 
language %q", language)
-       }
-       sourceRevision := source.Commit
-       if requestedRevision != "" {
-               sourceRevision = requestedRevision
-       }
-       for _, requirement := range generator.requirements {
-               if _, err := exec.LookPath(requirement); err != nil {
-                       return Result{}, fmt.Errorf("required command %q is not 
installed or not on PATH", requirement)
-               }
+               return Result{}, fmt.Errorf("unsupported snapshot language %q", 
language)
        }
-
-       workspace, err := os.MkdirTemp("", "datasketches-tck-"+language+"-")
+       workspace, err := os.MkdirTemp("", "datasketches-tck-check-")
        if err != nil {
-               return Result{}, fmt.Errorf("create generator workspace: %w", 
err)
+               return Result{}, err
        }
        defer func() { _ = os.RemoveAll(workspace) }()
+       return generate(ctx, root, workspace, language, source, source.Commit, 
commandRunner{stdout: stdout, stderr: stderr})
+}
 
-       generated := filepath.Join(workspace, "generated")
-       runner := commandRunner{stdout: stdout, stderr: stderr}
-       resolvedRevision, err := generator.run(
-               ctx,
-               workspace,
-               generated,
-               source.Repository,
-               sourceRevision,
-               runner,
-       )
+// Update generates every selected source before publishing any changes. Empty
+// language selects all sources; empty revision selects each remote's HEAD.
+// With skipUnstableOnly, unchanged revisions with only unstable content 
changes
+// retain their entire snapshot directory. Other sources are updated in full.
+func Update(ctx context.Context, root, language, revision string, 
skipUnstableOnly bool, stdout, stderr io.Writer) ([]Result, error) {
+       languages := Languages()
+       if language != "" {
+               languages = []string{language}
+       }
+       if revision == "" {
+               revision = "HEAD"
+       }
+       config, originalConfig, err := loadConfig(root)
        if err != nil {
-               return Result{}, fmt.Errorf("generate %s snapshots: %w", 
language, err)
+               return nil, err
        }
-
-       target := snapshotDirectory(repositoryRoot, language)
-       changes, err := compareDirectories(target, generated, 
generator.stability)
+       workspace, err := os.MkdirTemp("", "datasketches-tck-update-")
        if err != nil {
-               return Result{}, err
+               return nil, err
        }
+       defer func() { _ = os.RemoveAll(workspace) }()
 
-       result := Result{
-               Target:           target,
-               PreviousRevision: source.Commit,
-               Revision:         resolvedRevision,
-               Changes:          changes,
-       }
-       if mode == ModeCheck {
-               return result, nil
-       }
-       if mode == ModeSync {
-               if len(changes) > 0 {
-                       if err := replaceDirectory(target, generated); err != 
nil {
-                               return Result{}, err
+       var results []Result
+       var replacements []replacement
+       revisionChanged := false
+       for _, language := range languages {
+               source, found := config.source(language)
+               if !found {
+                       return nil, fmt.Errorf("unsupported snapshot language 
%q", language)
+               }
+               directory := filepath.Join(workspace, language)
+               result, err := generate(ctx, root, directory, language, source, 
revision, commandRunner{stdout: stdout, stderr: stderr})
+               if err != nil {
+                       return nil, fmt.Errorf("update aborted before 
publishing: %w", err)
+               }
+               results = append(results, result)
+               if skipUnstableOnly && result.UnstableOnly() {
+                       continue
+               }
+               if result.RevisionChanged() {
+                       if err := config.setCommit(language, result.Revision); 
err != nil {
+                               return nil, err
                        }
-                       result.Written = true
+                       revisionChanged = true
+               }
+               if len(result.Changes) > 0 {
+                       replacements = append(replacements, replacement{
+                               target: result.Target,
+                               source: filepath.Join(directory, "generated"),
+                       })
                }
-               return result, nil
-       }
-
-       revisionChanged := result.RevisionChanged()
-       if !revisionChanged && !result.HasBlockingChanges() {
-               // Nothing new upstream. Rewriting the probabilistic snapshots 
would only
-               // add binary churn to the history without adopting anything.
-               return result, nil
        }
        if revisionChanged {
-               if err := config.setCommit(language, resolvedRevision); err != 
nil {
-                       return Result{}, err
-               }
-               updatedConfig, err := encodeConfig(config)
+               content, err := encodeConfig(config)
                if err != nil {
-                       return Result{}, err
+                       return nil, err
                }
-               if err := replaceConfigFile(repositoryRoot, updatedConfig); err 
!= nil {
-                       return Result{}, err
+               nextConfig := filepath.Join(workspace, configFilename)
+               if err := os.WriteFile(nextConfig, content, 0o644); err != nil {
+                       return nil, err
                }
+               replacements = append(replacements, replacement{target: 
filepath.Join(root, configFilename), source: nextConfig})
        }
-       if len(changes) > 0 {
-               if err := replaceDirectory(target, generated); err != nil {
-                       if !revisionChanged {
-                               return Result{}, err
-                       }
-                       if restoreErr := replaceConfigFile(repositoryRoot, 
originalConfig); restoreErr != nil {
-                               return Result{}, fmt.Errorf(
-                                       "%w; restoring the previous config also 
failed: %v",
-                                       err,
-                                       restoreErr,
-                               )
-                       }
-                       return Result{}, err
-               }
-               result.Written = true
-       }
-       return result, nil
-}
-
-// snapshotDirectory returns where a language's snapshots are committed. The
-// serialization_test_data/<language>_generated_files layout matches the
-// DataSketches implementation repositories, so a TCK checkout can be used
-// directly as their test data root.
-func snapshotDirectory(repositoryRoot, language string) string {
-       return filepath.Join(repositoryRoot, "serialization_test_data", 
language+"_generated_files")
-}
-
-func replaceDirectory(target, generated string) error {
-       parent := filepath.Dir(target)
-       if err := os.MkdirAll(parent, 0o755); err != nil {
-               return fmt.Errorf("create snapshot parent directory: %w", err)
+       if err := ctx.Err(); err != nil {
+               return nil, err
        }
-
-       transaction, err := os.MkdirTemp(parent, 
"."+filepath.Base(target)+"-tck-")
+       // A long-running generation must not overwrite a pin edited in the 
meantime.
+       currentConfig, err := os.ReadFile(filepath.Join(root, configFilename))
        if err != nil {
-               return fmt.Errorf("create snapshot update transaction: %w", err)
+               return nil, err
        }
-       removeTransaction := true
-       defer func() {
-               if removeTransaction {
-                       _ = os.RemoveAll(transaction)
-               }
-       }()
-
-       next := filepath.Join(transaction, "next")
-       if err := copyDirectory(generated, next); err != nil {
-               return fmt.Errorf("stage generated snapshots: %w", err)
+       if !bytes.Equal(currentConfig, originalConfig) {
+               return nil, fmt.Errorf("config.toml changed during generation; 
no updates were published")
        }
-
-       previous := filepath.Join(transaction, "previous")
-       hadTarget := false
-       info, err := os.Lstat(target)
-       switch {
-       case err == nil:
-               if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
-                       return fmt.Errorf("replace snapshots: %s is not a 
directory", target)
-               }
-               hadTarget = true
-               if err := os.Rename(target, previous); err != nil {
-                       return fmt.Errorf("preserve current snapshots: %w", err)
-               }
-       case os.IsNotExist(err):
-       case err != nil:
-               return fmt.Errorf("inspect snapshots before update: %w", err)
+       if err := publish(root, replacements, stderr); err != nil {
+               return nil, err
        }
+       return results, nil
+}
 
-       if err := os.Rename(next, target); err != nil {
-               if !hadTarget {
-                       return fmt.Errorf("install generated snapshots: %w", 
err)
-               }
-               if restoreErr := os.Rename(previous, target); restoreErr == nil 
{
-                       return fmt.Errorf("install generated snapshots: %w", 
err)
-               } else {
-                       removeTransaction = false
-                       return fmt.Errorf(
-                               "install generated snapshots: %w; restoring 
previous snapshots also failed: %v; previous files remain at %s",
-                               err,
-                               restoreErr,
-                               previous,
-                       )
-               }
+func generate(ctx context.Context, root, workspace, language string, source 
snapshotSource, revision string, runner commandRunner) (Result, error) {
+       definition, found := generators[language]
+       if !found {
+               return Result{}, fmt.Errorf("unsupported snapshot language %q", 
language)
        }
-
-       if hadTarget {
-               if err := os.RemoveAll(previous); err != nil && !errors.Is(err, 
os.ErrNotExist) {
-                       return fmt.Errorf("remove previous snapshots after 
update: %w", err)
+       for _, requirement := range definition.requirements {
+               if _, err := exec.LookPath(requirement); err != nil {
+                       return Result{}, fmt.Errorf("required command %q is not 
installed or not on PATH", requirement)
                }
        }
-       return nil
-}
-
-func copyDirectory(source, destination string) error {
-       files, err := regularFiles(source)
-       if err != nil {
-               return err
+       if err := os.MkdirAll(workspace, 0o755); err != nil {
+               return Result{}, err
        }
-       if err := os.MkdirAll(destination, 0o755); err != nil {
-               return err
+       if _, err := fmt.Fprintf(runner.stdout, "Generate %s snapshots\n", 
language); err != nil {
+               return Result{}, err
        }
-       for relative, file := range files {
-               output := filepath.Join(destination, 
filepath.FromSlash(relative))
-               if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
-                       return err
-               }
-               if err := copyFile(file.filename, output); err != nil {
-                       return err
-               }
+       generated := filepath.Join(workspace, "generated")
+       resolvedRevision, err := definition.run(ctx, workspace, generated, 
source.Repository, revision, runner)
+       if err != nil {
+               return Result{}, fmt.Errorf("generate %s snapshots: %w", 
language, err)
        }
-       return nil
+       target := snapshotDirectory(root, language)
+       changes, err := compareDirectories(target, generated, 
definition.stability)
+       if err != nil {
+               return Result{}, err
+       }
+       return Result{Target: target, PreviousRevision: source.Commit, 
Revision: resolvedRevision, Changes: changes}, nil
+}
+
+func snapshotDirectory(root, language string) string {
+       return filepath.Join(root, "serialization_test_data", 
language+"_generated_files")
 }
diff --git a/internal/snapshots/reconcile_test.go 
b/internal/snapshots/reconcile_test.go
new file mode 100644
index 0000000..17f4f2b
--- /dev/null
+++ b/internal/snapshots/reconcile_test.go
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package snapshots
+
+import (
+       "bytes"
+       "context"
+       "errors"
+       "io"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "strings"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestUpdateGeneratesEntireBatchBeforePublishing(t *testing.T) {
+       root, oldRevision, newRevision := updateFixture(t)
+       calls := 0
+       for language, definition := range generators {
+               definition.generate = func(_ context.Context, paths 
generationPaths, _ commandRunner) error {
+                       config, _, err := loadConfig(root)
+                       require.NoError(t, err)
+                       for _, selected := range Languages() {
+                               source, _ := config.source(selected)
+                               require.Equal(t, oldRevision, source.Commit)
+                               requireTestFile(t, 
filepath.Join(snapshotDirectory(root, selected), "stable.sk"), "old")
+                       }
+                       calls++
+                       writeTestFile(t, filepath.Join(paths.destination, 
"stable.sk"), "new")
+                       return nil
+               }
+               generators[language] = definition
+       }
+       results, err := Update(t.Context(), root, "", "", false, io.Discard, 
io.Discard)
+       require.NoError(t, err)
+       require.Len(t, results, len(Languages()))
+       require.Equal(t, len(Languages()), calls)
+       config, _, err := loadConfig(root)
+       require.NoError(t, err)
+       for _, language := range Languages() {
+               source, _ := config.source(language)
+               require.Equal(t, newRevision, source.Commit)
+               requireTestFile(t, filepath.Join(snapshotDirectory(root, 
language), "stable.sk"), "new")
+       }
+}
+
+func TestUpdateGenerationFailureLeavesEntireBatchUntouched(t *testing.T) {
+       root, oldRevision, _ := updateFixture(t)
+       _, before, err := loadConfig(root)
+       require.NoError(t, err)
+       definition := generators["java"]
+       definition.generate = func(context.Context, generationPaths, 
commandRunner) error {
+               return errors.New("upstream generation failed")
+       }
+       generators["java"] = definition
+       results, err := Update(t.Context(), root, "", "", false, io.Discard, 
io.Discard)
+       require.ErrorContains(t, err, "update aborted before publishing")
+       require.Nil(t, results)
+       config, after, err := loadConfig(root)
+       require.NoError(t, err)
+       require.Equal(t, before, after)
+       for _, language := range Languages() {
+               source, _ := config.source(language)
+               require.Equal(t, oldRevision, source.Commit)
+               requireTestFile(t, filepath.Join(snapshotDirectory(root, 
language), "stable.sk"), "old")
+       }
+}
+
+func TestUpdateSkipsUnstableSourceWithinBatch(t *testing.T) {
+       root, _, newRevision := updateFixture(t)
+       config, _, err := loadConfig(root)
+       require.NoError(t, err)
+       require.NoError(t, config.setCommit("go", newRevision))
+       content, err := encodeConfig(config)
+       require.NoError(t, err)
+       writeTestFile(t, filepath.Join(root, configFilename), string(content))
+       target := snapshotDirectory(root, "go")
+       writeTestFile(t, filepath.Join(target, "stable.sk"), "new")
+       writeTestFile(t, filepath.Join(target, "unstable.sk"), "old")
+       definition := generators["go"]
+       generate := definition.generate
+       definition.generate = func(ctx context.Context, paths generationPaths, 
runner commandRunner) error {
+               if err := generate(ctx, paths, runner); err != nil {
+                       return err
+               }
+               writeTestFile(t, filepath.Join(paths.destination, 
"unstable.sk"), "new")
+               return nil
+       }
+       definition.stability = func(path string) Stability {
+               if path == "unstable.sk" {
+                       return Unstable
+               }
+               return Stable
+       }
+       generators["go"] = definition
+       _, err = Update(t.Context(), root, "", "", true, io.Discard, io.Discard)
+       require.NoError(t, err)
+       config, _, err = loadConfig(root)
+       require.NoError(t, err)
+       for _, language := range Languages() {
+               source, _ := config.source(language)
+               require.Equal(t, newRevision, source.Commit)
+               requireTestFile(t, filepath.Join(snapshotDirectory(root, 
language), "stable.sk"), "new")
+       }
+       requireTestFile(t, filepath.Join(target, "unstable.sk"), "old")
+}
+
+func TestUpdateUnstableOnlyPolicy(t *testing.T) {
+       tests := []struct {
+               name      string
+               skip      bool
+               advance   bool
+               generated map[string]string
+               keepOld   bool
+       }{
+               {name: "default refreshes unstable contents", generated: 
map[string]string{"stable.sk": "old", "unstable.sk": "new"}},
+               {name: "skip retains unchanged source", skip: true, generated: 
map[string]string{"stable.sk": "old", "unstable.sk": "new"}, keepOld: true},
+               {name: "new revision includes unstable contents", skip: true, 
advance: true, generated: map[string]string{"stable.sk": "old", "unstable.sk": 
"new"}},
+               {name: "stable change includes unstable contents", skip: true, 
generated: map[string]string{"stable.sk": "new", "unstable.sk": "new"}},
+               {name: "unstable addition is adopted", skip: true, generated: 
map[string]string{"stable.sk": "old", "unstable.sk": "new", "added.sk": "new"}},
+               {name: "unstable deletion is adopted", skip: true, generated: 
map[string]string{"stable.sk": "old"}},
+               {name: "revision only", skip: true, advance: true, generated: 
map[string]string{"stable.sk": "old", "unstable.sk": "old"}},
+               {name: "no changes", skip: true, generated: 
map[string]string{"stable.sk": "old", "unstable.sk": "old"}},
+       }
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       root, oldRevision, newRevision := updateFixture(t)
+                       target := snapshotDirectory(root, "go")
+                       writeTestFile(t, filepath.Join(target, "unstable.sk"), 
"old")
+                       definition := generators["go"]
+                       definition.generate = func(_ context.Context, paths 
generationPaths, _ commandRunner) error {
+                               for name, content := range test.generated {
+                                       writeTestFile(t, 
filepath.Join(paths.destination, name), content)
+                               }
+                               return nil
+                       }
+                       definition.stability = func(path string) Stability {
+                               if path == "stable.sk" {
+                                       return Stable
+                               }
+                               return Unstable
+                       }
+                       generators["go"] = definition
+                       revision := oldRevision
+                       if test.advance {
+                               revision = newRevision
+                       }
+                       results, err := Update(t.Context(), root, "go", 
revision, test.skip, io.Discard, io.Discard)
+                       require.NoError(t, err)
+                       require.Len(t, results, 1)
+                       config, _, err := loadConfig(root)
+                       require.NoError(t, err)
+                       require.Equal(t, revision, config.Snapshot.Go.Commit)
+                       require.Equal(t, oldRevision, 
config.Snapshot.CPP.Commit)
+                       require.Equal(t, oldRevision, 
config.Snapshot.Java.Commit)
+                       expected := test.generated
+                       if test.keepOld {
+                               expected = map[string]string{"stable.sk": 
"old", "unstable.sk": "old"}
+                       }
+                       entries, err := os.ReadDir(target)
+                       require.NoError(t, err)
+                       require.Len(t, entries, len(expected))
+                       for name, content := range expected {
+                               requireTestFile(t, filepath.Join(target, name), 
content)
+                       }
+               })
+       }
+}
+
+func TestCheckUsesPinnedRevisionWithoutWriting(t *testing.T) {
+       root, oldRevision, _ := updateFixture(t)
+       _, before, err := loadConfig(root)
+       require.NoError(t, err)
+       result, err := Check(t.Context(), root, "go", io.Discard, io.Discard)
+       require.NoError(t, err)
+       require.Equal(t, oldRevision, result.Revision)
+       require.Empty(t, result.Changes)
+       _, after, err := loadConfig(root)
+       require.NoError(t, err)
+       require.Equal(t, before, after)
+       requireTestFile(t, filepath.Join(snapshotDirectory(root, "go"), 
"stable.sk"), "old")
+}
+
+func TestUpdatePreservesConcurrentConfigEdit(t *testing.T) {
+       root, _, _ := updateFixture(t)
+       _, before, err := loadConfig(root)
+       require.NoError(t, err)
+       edited := append(bytes.Clone(before), []byte("\n# Maintainer 
edit\n")...)
+       definition := generators["go"]
+       generate := definition.generate
+       definition.generate = func(ctx context.Context, paths generationPaths, 
runner commandRunner) error {
+               require.NoError(t, os.WriteFile(filepath.Join(root, 
configFilename), edited, 0o644))
+               return generate(ctx, paths, runner)
+       }
+       generators["go"] = definition
+       _, err = Update(t.Context(), root, "go", "", false, io.Discard, 
io.Discard)
+       require.ErrorContains(t, err, "config.toml changed during generation")
+       after, err := os.ReadFile(filepath.Join(root, configFilename))
+       require.NoError(t, err)
+       require.Equal(t, edited, after)
+       requireTestFile(t, filepath.Join(snapshotDirectory(root, "go"), 
"stable.sk"), "old")
+}
+
+func updateFixture(t *testing.T) (string, string, string) {
+       t.Helper()
+       source := t.TempDir()
+       git := func(args ...string) string {
+               t.Helper()
+               args = append([]string{"-C", source, "-c", "user.name=TCK 
test", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, 
args...)
+               output, err := exec.Command("git", args...).CombinedOutput()
+               require.NoError(t, err, "%s", output)
+               return strings.TrimSpace(string(output))
+       }
+       git("init", "--quiet")
+       writeTestFile(t, filepath.Join(source, "stable.sk"), "old")
+       git("add", "--force", "stable.sk")
+       git("commit", "--quiet", "-m", "Initial snapshot")
+       oldRevision := git("rev-parse", "HEAD")
+       writeTestFile(t, filepath.Join(source, "stable.sk"), "new")
+       git("commit", "--quiet", "-am", "Update snapshot")
+       newRevision := git("rev-parse", "HEAD")
+
+       original := generators
+       t.Cleanup(func() { generators = original })
+       generators = make(map[string]generator)
+       for language := range original {
+               generators[language] = generator{
+                       requirements: []string{"git"},
+                       generate: func(_ context.Context, paths 
generationPaths, _ commandRunner) error {
+                               if err := os.MkdirAll(paths.destination, 
0o755); err != nil {
+                                       return err
+                               }
+                               return copyFile(filepath.Join(paths.source, 
"stable.sk"), filepath.Join(paths.destination, "stable.sk"))
+                       },
+                       stability: func(string) Stability { return Stable },
+               }
+       }
+       root := t.TempDir()
+       pin := snapshotSource{Repository: source, Commit: oldRevision}
+       content, err := encodeConfig(repositoryConfig{Snapshot: 
snapshotSources{CPP: pin, Go: pin, Java: pin}})
+       require.NoError(t, err)
+       writeTestFile(t, filepath.Join(root, configFilename), string(content))
+       for _, language := range Languages() {
+               writeTestFile(t, filepath.Join(snapshotDirectory(root, 
language), "stable.sk"), "old")
+       }
+       return root, oldRevision, newRevision
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to