Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package rumdl for openSUSE:Factory checked 
in at 2026-09-24 22:58:02
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/rumdl (Old)
 and      /work/SRC/openSUSE:Factory/.rumdl.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "rumdl"

Thu Sep 24 22:58:02 2026 rev:102 rq:1380072 version:0.2.77

Changes:
--------
--- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes      2026-09-23 
14:37:09.541831673 +0200
+++ /work/SRC/openSUSE:Factory/.rumdl.new.383539/rumdl.changes  2026-09-24 
23:00:25.753679318 +0200
@@ -1,0 +2,10 @@
+Thu Sep 24 04:55:42 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 0.2.77:
+  * Fixed
+    - release: never replace published GitHub Release assets
+      (471d97e)
+  * Performance
+    - release: cut published wheel size 8% with fat LTO (7605780)
+
+-------------------------------------------------------------------

Old:
----
  rumdl-0.2.76.obscpio

New:
----
  rumdl-0.2.77.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ rumdl.spec ++++++
--- /var/tmp/diff_new_pack.DWpjgS/_old  2026-09-24 23:00:31.493919341 +0200
+++ /var/tmp/diff_new_pack.DWpjgS/_new  2026-09-24 23:00:31.498919550 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           rumdl
-Version:        0.2.76
+Version:        0.2.77
 Release:        0
 Summary:        Markdown Linter written in Rust
 # Legal-Review-Notice: MPL-2.0 from the colored crate (statically linked).

++++++ _service ++++++
--- /var/tmp/diff_new_pack.DWpjgS/_old  2026-09-24 23:00:31.578922895 +0200
+++ /var/tmp/diff_new_pack.DWpjgS/_new  2026-09-24 23:00:31.589923355 +0200
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/rvben/rumdl.git</param>
     <param name="scm">git</param>
     <param name="submodules">enable</param>
-    <param name="revision">refs/tags/v0.2.76</param>
+    <param name="revision">refs/tags/v0.2.77</param>
     <param name="match-tag">v*.*.*</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.DWpjgS/_old  2026-09-24 23:00:31.631925111 +0200
+++ /var/tmp/diff_new_pack.DWpjgS/_new  2026-09-24 23:00:31.640925488 +0200
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param name="url">https://github.com/rvben/rumdl.git</param>
-              <param 
name="changesrevision">389e12baa4571b7640bb9f0687cf55946da89e4f</param></service></servicedata>
+              <param 
name="changesrevision">bb84a49a85625b31ecf6c3e58b7bca7c75fbfefb</param></service></servicedata>
 (No newline at EOF)
 

++++++ rumdl-0.2.76.obscpio -> rumdl-0.2.77.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/.github/workflows/ci.yml 
new/rumdl-0.2.77/.github/workflows/ci.yml
--- old/rumdl-0.2.76/.github/workflows/ci.yml   2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/.github/workflows/ci.yml   2026-09-23 15:05:26.000000000 
+0200
@@ -50,6 +50,11 @@
       - name: Check version references in sync with Cargo.toml
         run: make check-versions
 
+      # The release workflow's write-once guard for GitHub Release archives
+      # runs only at release time; its logic is tested here, on every push.
+      - name: Test release asset guard
+        run: make test-release-scripts
+
       - name: Check Rust formatting
         run: make fmt-check
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/.github/workflows/release.yml 
new/rumdl-0.2.77/.github/workflows/release.yml
--- old/rumdl-0.2.76/.github/workflows/release.yml      2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/.github/workflows/release.yml      2026-09-23 
15:05:26.000000000 +0200
@@ -636,6 +636,11 @@
     # suite runs in parallel with the build matrix while still blocking every
     # publish step below.
     needs: [test, build, sdist, test-python-package, build-wasm, test-npm-cli, 
test-npm-cli-musl]
+    outputs:
+      # Whether this run added archives to the GitHub Release. False on a
+      # recovery run over an already-published release, whose rebuilt archives
+      # are discarded; consumers of the archives are notified only when true.
+      new_archives: ${{ steps.asset_plan.outputs.new_archives }}
     permissions:
       contents: write       # Create GitHub release + update major version tag
       id-token: write       # npm OIDC trusted publishing + Sigstore 
attestations
@@ -892,6 +897,25 @@
             ./scripts/generate-downloads-table.sh "$TAG_NAME" >> 
release-notes.md
           fi
 
+      # GitHub Release archives are write-once, like every registry version.
+      # Rust builds are not byte-reproducible, so a recovery dispatch over a
+      # published release (a PyPI backfill, a re-run after a later publish
+      # step failed) rebuilds every archive with new digests; replacing the
+      # live ones breaks everything that pinned the first digest (mise, aqua,
+      # lockfiles, Nix, the Homebrew tap), which is how v0.2.73 and v0.2.76
+      # had their assets swapped after publication. The plan runs in dry runs
+      # too, so its release lookup is exercised before any real release.
+      - name: Plan GitHub Release assets
+        id: asset_plan
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+        run: |
+          python3 scripts/release_assets.py plan --tag "${GITHUB_REF_NAME}" \
+            --artifacts artifacts --snapshot 
"${RUNNER_TEMP}/release-assets.json"
+
+      # overwrite_files: false skips an asset that is already live instead of
+      # deleting and re-uploading it; the release body is still updated.
       - name: Create Release
         if: ${{ inputs.dry_run != true }}
         uses: softprops/action-gh-release@v2
@@ -899,12 +923,23 @@
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:
           body_path: release-notes.md
+          overwrite_files: false
+          fail_on_unmatched_files: true
           files: |
             artifacts/release-*/rumdl-*.tar.gz
             artifacts/release-*/rumdl-*.tar.gz.sha256
             artifacts/release-*/rumdl-*.zip
             artifacts/release-*/rumdl-*.zip.sha256
 
+      - name: Verify GitHub Release assets
+        if: ${{ inputs.dry_run != true }}
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+        run: |
+          python3 scripts/release_assets.py verify --tag "${GITHUB_REF_NAME}" \
+            --artifacts artifacts --snapshot 
"${RUNNER_TEMP}/release-assets.json"
+
       - name: Test Release Creation (dry run)
         if: ${{ inputs.dry_run == true }}
         run: |
@@ -979,7 +1014,10 @@
             https://api.github.com/repos/rvben/rumdl-pre-commit/dispatches \
             -d "{\"event_type\": \"pypi_release\", \"client_payload\": 
{\"version\": \"$VERSION\"}}"
 
+      # The extension bundles the GitHub Release archives, so it rebuilds only
+      # when this run published new ones.
       - name: Notify rumdl-vscode
+        if: ${{ needs.release.outputs.new_archives == 'true' }}
         continue-on-error: true
         env:
           GITHUB_TOKEN: ${{ secrets.VSCODE_DISPATCH_TOKEN }}
@@ -999,7 +1037,10 @@
             https://api.github.com/repos/rvben/rumdl-vscode/dispatches \
             -d "{\"event_type\": \"rumdl_release\", \"client_payload\": 
{\"version\": \"$VERSION\"}}"
 
+      # The formula pins the GitHub Release archives' digests, so it changes
+      # only when this run published new archives.
       - name: Notify homebrew-rumdl
+        if: ${{ needs.release.outputs.new_archives == 'true' }}
         continue-on-error: true
         env:
           GITHUB_TOKEN: ${{ secrets.HOMEBREW_DISPATCH_TOKEN }}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/.pre-commit-config.yaml 
new/rumdl-0.2.77/.pre-commit-config.yaml
--- old/rumdl-0.2.76/.pre-commit-config.yaml    2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/.pre-commit-config.yaml    2026-09-23 15:05:26.000000000 
+0200
@@ -58,7 +58,7 @@
         stages: [pre-commit]
 
   - repo: https://github.com/rhysd/actionlint
-    rev: v1.7.7
+    rev: v1.7.12
     hooks:
       - id: actionlint
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/CHANGELOG.md 
new/rumdl-0.2.77/CHANGELOG.md
--- old/rumdl-0.2.76/CHANGELOG.md       2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/CHANGELOG.md       2026-09-23 15:05:26.000000000 +0200
@@ -7,6 +7,16 @@
 
 ## [Unreleased]
 
+## [0.2.77](https://github.com/rvben/rumdl/compare/v0.2.76...v0.2.77) - 
2026-09-23
+
+### Fixed
+
+- **release**: never replace published GitHub Release assets 
([471d97e](https://github.com/rvben/rumdl/commit/471d97ee93aaf8e71e116213731157aebb5e0ec1))
+
+### Performance
+
+- **release**: cut published wheel size 8% with fat LTO 
([7605780](https://github.com/rvben/rumdl/commit/7605780a4c45aecf83d1d12e399edc1fd7dccc58))
+
 ## [0.2.76](https://github.com/rvben/rumdl/compare/v0.2.75...v0.2.76) - 
2026-09-23
 
 ### Added
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/Cargo.lock new/rumdl-0.2.77/Cargo.lock
--- old/rumdl-0.2.76/Cargo.lock 2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/Cargo.lock 2026-09-23 15:05:26.000000000 +0200
@@ -2462,7 +2462,7 @@
 
 [[package]]
 name = "rumdl"
-version = "0.2.76"
+version = "0.2.77"
 dependencies = [
  "assert_cmd",
  "blake3",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/Cargo.toml new/rumdl-0.2.77/Cargo.toml
--- old/rumdl-0.2.76/Cargo.toml 2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/Cargo.toml 2026-09-23 15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 [package]
 name = "rumdl"
-version = "0.2.76"
+version = "0.2.77"
 edition = "2024"
 rust-version = "1.94.0"
 description = "A fast Markdown linter and formatter written in Rust"
@@ -26,12 +26,16 @@
 path = "src/main.rs"
 
 [profile.release]
-lto = "thin"         # Thin LTO: keeps cross-crate inlining, links far faster 
than fat LTO
+lto = "fat"          # Fat LTO across the whole graph
 strip = true         # Strip symbols from binary
 opt-level = 3        # Optimize for speed
-codegen-units = 16   # Parallel codegen; with thin LTO this cuts build time 
~3.4x
-                     # for no measured lint-speed regression (fat LTO + 1 unit 
was
-                     # the dominant cost of the release build matrix)
+codegen-units = 1    # One unit, so LTO sees everything. Fat LTO + 1 unit costs
+                     # 3.5x the clean build time of thin LTO + 16 units (383s 
vs
+                     # 109s) and buys 8% off every published wheel at no 
runtime
+                     # cost (user CPU -1.3% over a 478-file corpus). Published
+                     # artifact size is worth more here than release build 
time;
+                     # the dev profile is unaffected. Do not lower opt-level to
+                     # shrink further: "s" costs 15% CPU and "z" costs 113%.
 
 [profile.profiling]
 inherits = "release"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/Makefile new/rumdl-0.2.77/Makefile
--- old/rumdl-0.2.76/Makefile   2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/Makefile   2026-09-23 15:05:26.000000000 +0200
@@ -1,4 +1,4 @@
-.PHONY: build test clean fmt check doc doc-check build-python build-wheel 
dev-install setup-mise dev-setup dev-verify update-dependencies 
update-rust-version build-static-linux-x64 build-static-linux-arm64 
build-static-all docker-binaries docker-binaries-release docker-binfmt 
docker-builder docker-build docker-verify docker-push schema check-schema 
sync-code-block-tools check-code-block-tools test-code-block-tools 
check-versions benchmark benchmark-run benchmark-chart lint-actions 
lint-actions-all fuzz fuzz-long check-links docs-check docs-sanitize 
docs-sanitize-test docs-sitemap docs-sitemap-test docs-benchmark-test 
docs-smoke docs-descriptions docs-discoverability docs-analytics sync-rule-docs 
check-rule-docs release-patch release-minor release-major test-idempotency 
test-doc test-doc-completeness fuzz-all check-fuzz audit msrv-check smoke-wasi 
parity
+.PHONY: build test clean fmt check doc doc-check build-python build-wheel 
dev-install setup-mise dev-setup dev-verify update-dependencies 
update-rust-version build-static-linux-x64 build-static-linux-arm64 
build-static-all docker-binaries docker-binaries-release docker-binfmt 
docker-builder docker-build docker-verify docker-push schema check-schema 
sync-code-block-tools check-code-block-tools test-code-block-tools 
check-versions benchmark benchmark-run benchmark-chart lint-actions 
lint-actions-all fuzz fuzz-long check-links docs-check docs-sanitize 
docs-sanitize-test docs-sitemap docs-sitemap-test docs-benchmark-test 
docs-smoke docs-descriptions docs-discoverability docs-analytics sync-rule-docs 
check-rule-docs test-release-scripts release-patch release-minor release-major 
test-idempotency test-doc test-doc-completeness fuzz-all check-fuzz audit 
msrv-check smoke-wasi parity
 
 # Development environment setup
 setup-mise:
@@ -209,12 +209,26 @@
 # Build and publish the multi-arch images for every flavour, with BuildKit
 # SBOM and provenance attestations attached to the manifests, then assert
 # the pushed manifests really contain every target platform.
+#
+# A published version tag is never re-pushed. Release binaries are not
+# byte-reproducible, so a release workflow re-run over an existing version
+# would move :VERSION to a new digest; a flavour whose version tag already
+# exists is skipped, and only a definite "not found" counts as absent (any
+# other lookup failure stops the push rather than guessing).
 docker-push: docker-builder
        for flavor in $(DOCKER_FLAVORS); do \
                case $$flavor in \
-                       scratch) tags="-t $(DOCKER_IMAGE):$(VERSION) -t 
$(DOCKER_IMAGE):latest" ;; \
-                       *) tags="-t $(DOCKER_IMAGE):$(VERSION)-$$flavor -t 
$(DOCKER_IMAGE):$$flavor" ;; \
+                       scratch) ref="$(DOCKER_IMAGE):$(VERSION)"; tags="-t 
$(DOCKER_IMAGE):$(VERSION) -t $(DOCKER_IMAGE):latest" ;; \
+                       *) ref="$(DOCKER_IMAGE):$(VERSION)-$$flavor"; tags="-t 
$(DOCKER_IMAGE):$(VERSION)-$$flavor -t $(DOCKER_IMAGE):$$flavor" ;; \
                esac && \
+               if lookup=$$(docker buildx imagetools inspect "$$ref" 2>&1); 
then \
+                       echo "==> $$ref is already published; not re-pushing 
flavour $$flavor"; \
+                       continue; \
+               elif [ "$$lookup" != "ERROR: $$ref: not found" ]; then \
+                       echo "error: could not tell whether $$ref exists:" >&2; 
\
+                       echo "$$lookup" >&2; \
+                       exit 1; \
+               fi && \
                echo "==> Pushing flavour $$flavor" && \
                docker buildx build \
                        --builder $(DOCKER_BUILDER) \
@@ -472,6 +486,12 @@
        python3 scripts/test_check_rule_docs.py
        python3 scripts/check-rule-docs.py
 
+# Hermetic tests for the release workflow's GitHub Release asset guard
+# (scripts/release_assets.py), which otherwise executes only when a tag is
+# released.
+test-release-scripts:
+       python3 scripts/release_assets_test.py
+
 doc:
        cargo doc --no-deps
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/README.md new/rumdl-0.2.77/README.md
--- old/rumdl-0.2.76/README.md  2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/README.md  2026-09-23 15:05:26.000000000 +0200
@@ -226,7 +226,7 @@
 mise install rumdl
 
 # Use a specific version for the project
-mise use [email protected]
+mise use [email protected]
 ```
 
 ### Using Nix (macOS/Linux)
@@ -458,7 +458,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.2.76
+    rev: v0.2.77
     hooks:
       - id: rumdl      # Lint only; add args [--fix] to auto-fix
       - id: rumdl-fmt  # Pure format, exits 0 on violations
@@ -474,7 +474,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.2.76
+    rev: v0.2.77
     hooks:
       - id: rumdl
         args: [--fix]  # Auto-fix violations in place
@@ -491,7 +491,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.2.76
+    rev: v0.2.77
     hooks:
       - id: rumdl
         args: [--no-exclude]  # Disable all exclude patterns
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/docs/getting-started/quickstart.md 
new/rumdl-0.2.77/docs/getting-started/quickstart.md
--- old/rumdl-0.2.76/docs/getting-started/quickstart.md 2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/docs/getting-started/quickstart.md 2026-09-23 
15:05:26.000000000 +0200
@@ -106,7 +106,7 @@
 ```yaml
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.2.76  # Use latest version
+    rev: v0.2.77  # Use latest version
     hooks:
       - id: rumdl
 ```
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/docs/global-settings.md 
new/rumdl-0.2.77/docs/global-settings.md
--- old/rumdl-0.2.76/docs/global-settings.md    2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/docs/global-settings.md    2026-09-23 15:05:26.000000000 
+0200
@@ -1599,7 +1599,7 @@
 
 ```yaml
 - repo: https://github.com/rvben/rumdl-pre-commit
-  rev: v0.2.76
+  rev: v0.2.77
   hooks:
     - id: rumdl
       args: [--config=.rumdl.toml]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/docs/mdformat-comparison.md 
new/rumdl-0.2.77/docs/mdformat-comparison.md
--- old/rumdl-0.2.76/docs/mdformat-comparison.md        2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/docs/mdformat-comparison.md        2026-09-23 
15:05:26.000000000 +0200
@@ -236,7 +236,7 @@
 
    # After
    - repo: https://github.com/rvben/rumdl-pre-commit
-     rev: v0.2.76
+     rev: v0.2.77
      hooks:
        - id: rumdl
     ```
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/docs/usage/ci-cd.md 
new/rumdl-0.2.77/docs/usage/ci-cd.md
--- old/rumdl-0.2.76/docs/usage/ci-cd.md        2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/docs/usage/ci-cd.md        2026-09-23 15:05:26.000000000 
+0200
@@ -66,7 +66,7 @@
 ```yaml
 - uses: rvben/rumdl@v0
   with:
-    version: "0.2.76"
+    version: "0.2.77"
     path: docs/
 ```
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/docs/usage/pre-commit.md 
new/rumdl-0.2.77/docs/usage/pre-commit.md
--- old/rumdl-0.2.76/docs/usage/pre-commit.md   2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/docs/usage/pre-commit.md   2026-09-23 15:05:26.000000000 
+0200
@@ -20,7 +20,7 @@
 ```yaml title=".pre-commit-config.yaml"
 repos:
   - repo: https://github.com/rvben/rumdl-pre-commit
-    rev: v0.2.76  # Use latest version
+    rev: v0.2.77  # Use latest version
     hooks:
       - id: rumdl      # Lint only; add args [--fix] to auto-fix
       - id: rumdl-fmt  # Pure format, exits 0 on violations
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-darwin-arm64/package.json 
new/rumdl-0.2.77/npm/cli-darwin-arm64/package.json
--- old/rumdl-0.2.76/npm/cli-darwin-arm64/package.json  2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-darwin-arm64/package.json  2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-arm64",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for macOS ARM64 (Apple Silicon)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-darwin-x64/package.json 
new/rumdl-0.2.77/npm/cli-darwin-x64/package.json
--- old/rumdl-0.2.76/npm/cli-darwin-x64/package.json    2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-darwin-x64/package.json    2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-darwin-x64",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for macOS x64 (Intel)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-linux-arm64/package.json 
new/rumdl-0.2.77/npm/cli-linux-arm64/package.json
--- old/rumdl-0.2.76/npm/cli-linux-arm64/package.json   2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-linux-arm64/package.json   2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for Linux ARM64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-linux-arm64-musl/package.json 
new/rumdl-0.2.77/npm/cli-linux-arm64-musl/package.json
--- old/rumdl-0.2.76/npm/cli-linux-arm64-musl/package.json      2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-linux-arm64-musl/package.json      2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-arm64-musl",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for Linux ARM64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-linux-x64/package.json 
new/rumdl-0.2.77/npm/cli-linux-x64/package.json
--- old/rumdl-0.2.76/npm/cli-linux-x64/package.json     2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-linux-x64/package.json     2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for Linux x64 (glibc)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-linux-x64-musl/package.json 
new/rumdl-0.2.77/npm/cli-linux-x64-musl/package.json
--- old/rumdl-0.2.76/npm/cli-linux-x64-musl/package.json        2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-linux-x64-musl/package.json        2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-linux-x64-musl",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for Linux x64 (musl/Alpine)",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/cli-win32-x64/package.json 
new/rumdl-0.2.77/npm/cli-win32-x64/package.json
--- old/rumdl-0.2.76/npm/cli-win32-x64/package.json     2026-09-23 
02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/npm/cli-win32-x64/package.json     2026-09-23 
15:05:26.000000000 +0200
@@ -1,6 +1,6 @@
 {
   "name": "@rumdl/cli-win32-x64",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "rumdl binary for Windows x64",
   "license": "MIT",
   "repository": {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/npm/rumdl/package.json 
new/rumdl-0.2.77/npm/rumdl/package.json
--- old/rumdl-0.2.76/npm/rumdl/package.json     2026-09-23 02:55:25.000000000 
+0200
+++ new/rumdl-0.2.77/npm/rumdl/package.json     2026-09-23 15:05:26.000000000 
+0200
@@ -1,6 +1,6 @@
 {
   "name": "rumdl",
-  "version": "0.2.76",
+  "version": "0.2.77",
   "description": "A fast Markdown linter and formatter written in Rust",
   "license": "MIT",
   "repository": {
@@ -33,12 +33,12 @@
     "node": ">=18.0.0"
   },
   "optionalDependencies": {
-    "@rumdl/cli-darwin-x64": "0.2.76",
-    "@rumdl/cli-darwin-arm64": "0.2.76",
-    "@rumdl/cli-linux-x64": "0.2.76",
-    "@rumdl/cli-linux-arm64": "0.2.76",
-    "@rumdl/cli-linux-x64-musl": "0.2.76",
-    "@rumdl/cli-linux-arm64-musl": "0.2.76",
-    "@rumdl/cli-win32-x64": "0.2.76"
+    "@rumdl/cli-darwin-x64": "0.2.77",
+    "@rumdl/cli-darwin-arm64": "0.2.77",
+    "@rumdl/cli-linux-x64": "0.2.77",
+    "@rumdl/cli-linux-arm64": "0.2.77",
+    "@rumdl/cli-linux-x64-musl": "0.2.77",
+    "@rumdl/cli-linux-arm64-musl": "0.2.77",
+    "@rumdl/cli-win32-x64": "0.2.77"
   }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/rules.json new/rumdl-0.2.77/rules.json
--- old/rumdl-0.2.76/rules.json 2026-09-23 02:55:25.000000000 +0200
+++ new/rumdl-0.2.77/rules.json 2026-09-23 15:05:26.000000000 +0200
@@ -876,5 +876,15 @@
     "fix": "Fix is not available.",
     "fix_availability": "None",
     "url": "https://rumdl.dev/md093/";
+  },
+  {
+    "code": "MD094",
+    "name": "invalid-encoding",
+    "aliases": [],
+    "summary": "File is not valid UTF-8",
+    "category": "other",
+    "fix": "Fix is not available.",
+    "fix_availability": "None",
+    "url": "https://rumdl.dev/md094/";
   }
 ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/scripts/release_assets.py 
new/rumdl-0.2.77/scripts/release_assets.py
--- old/rumdl-0.2.76/scripts/release_assets.py  1970-01-01 01:00:00.000000000 
+0100
+++ new/rumdl-0.2.77/scripts/release_assets.py  2026-09-23 15:05:26.000000000 
+0200
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""Keep a GitHub Release's archives write-once across release workflow runs.
+
+Rust builds are not byte-reproducible, so every run of the release workflow
+produces archives with new digests even from an unchanged tag. A recovery
+dispatch over an already-published release (a PyPI backfill, a re-run after a
+later publish step failed) must therefore never replace an archive that is
+already live: anything that pinned the first digest (mise, aqua, lockfiles,
+Nix, the Homebrew tap) would then reject the download as tampered. The
+registries enforce this themselves (crates.io, PyPI and npm keep the first
+upload of a version); a GitHub Release does not, so this script does.
+
+    plan    Before the upload. Compares the run's local archives with the live
+            release, refuses a platform whose archive and .sha256 sidecar are
+            only half published (uploading the missing half would pair a
+            rebuilt checksum with the original archive, or the reverse), and
+            records the live digests as a snapshot. Emits
+            `new_archives=true|false` to $GITHUB_OUTPUT: whether this run
+            publishes any archive, which decides whether consumers of the
+            archives (Homebrew tap, VS Code extension) are notified.
+
+    verify  After the upload. Every archive of this run is live, every live
+            archive's digest equals the hash in its live sidecar, and every
+            archive recorded in the plan snapshot still has its recorded
+            digest.
+
+The upload itself must skip existing assets (`overwrite_files: false` on
+softprops/action-gh-release); `verify` is what proves it did.
+
+Usage:
+    release_assets.py plan   --tag vX.Y.Z --artifacts DIR --snapshot FILE
+    release_assets.py verify --tag vX.Y.Z --artifacts DIR --snapshot FILE
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+ARCHIVE_SUFFIXES = (".tar.gz", ".zip")
+SIDECAR_SUFFIX = ".sha256"
+_HEX64 = re.compile(r"^[0-9a-f]{64}$")
+
+
+class ReleaseAssetError(Exception):
+    """A condition that must stop the release before (or after) publishing."""
+
+
+def is_archive(name: str) -> bool:
+    return name.endswith(ARCHIVE_SUFFIXES)
+
+
+def sidecar_name(archive: str) -> str:
+    return archive + SIDECAR_SUFFIX
+
+
+def parse_sidecar(text: str, source: str) -> str:
+    """Return the lowercase hex digest a .sha256 sidecar records.
+
+    Two formats are published: `sha256sum`/`shasum` output (`<hex>  <name>`)
+    for the tarballs, and PowerShell's `Get-FileHash` (uppercase hex, CRLF) for
+    the Windows zip. Anything else is an error, never a guess.
+    """
+    fields = text.split()
+    if not fields:
+        raise ReleaseAssetError(f"{source}: empty checksum file")
+    digest = fields[0].lower()
+    if not _HEX64.match(digest):
+        raise ReleaseAssetError(f"{source}: first field is not a sha256 hex 
digest: {fields[0]!r}")
+    return digest
+
+
+def normalize_digest(digest: str | None) -> str | None:
+    """GitHub reports asset digests as `sha256:<hex>`; older assets have 
none."""
+    if not digest:
+        return None
+    algo, _, value = digest.partition(":")
+    if algo != "sha256" or not _HEX64.match(value):
+        raise ReleaseAssetError(f"unexpected asset digest format: {digest!r}")
+    return value
+
+
+@dataclass(frozen=True)
+class Plan:
+    upload: list[str]
+    kept: dict[str, str | None]
+    live_only: list[str]
+
+    @property
+    def new_archives(self) -> bool:
+        return bool(self.upload)
+
+
+def make_plan(local_archives: list[str], live: dict[str, str | None]) -> Plan:
+    """Decide which of this run's archives may be uploaded.
+
+    `live` maps every live asset name to its digest (None when GitHub has not
+    recorded one). An archive is uploaded only when neither it nor its sidecar
+    is live, and kept only when both are; a half-published pair is refused.
+    """
+    upload: list[str] = []
+    kept: dict[str, str | None] = {}
+    half: list[str] = []
+    for archive in sorted(local_archives):
+        has_archive = archive in live
+        has_sidecar = sidecar_name(archive) in live
+        if has_archive and has_sidecar:
+            kept[archive] = live[archive]
+        elif not has_archive and not has_sidecar:
+            upload.append(archive)
+        else:
+            present = archive if has_archive else sidecar_name(archive)
+            half.append(present)
+    if half:
+        raise ReleaseAssetError(
+            "these assets are live without their archive/checksum partner: "
+            + ", ".join(half)
+            + ". Uploading the missing half from this run would pair a rebuilt 
file with the "
+            "original, so the checksum would not match the archive. Delete the 
orphan by hand "
+            "(nobody can have installed from an incomplete pair) and re-run."
+        )
+    local = set(local_archives)
+    live_only = sorted(name for name in live if is_archive(name) and name not 
in local)
+    return Plan(upload=upload, kept=kept, live_only=live_only)
+
+
+def verify_release(
+    local_archives: list[str],
+    live: dict[str, str | None],
+    snapshot: dict[str, str | None],
+    read_sidecar: Callable[[str], str],
+    hash_asset: Callable[[str], str],
+) -> list[str]:
+    """Return every violation of the write-once contract (empty when clean).
+
+    `read_sidecar(name)` returns a live sidecar's text; `hash_asset(name)`
+    downloads a live archive and hashes it, used only when GitHub has no
+    digest on record for it.
+    """
+    problems: list[str] = []
+    for archive in sorted(local_archives):
+        if archive not in live:
+            problems.append(f"{archive}: not on the release after the upload")
+        if sidecar_name(archive) not in live:
+            problems.append(f"{sidecar_name(archive)}: not on the release 
after the upload")
+
+    for archive in sorted(name for name in live if is_archive(name)):
+        if sidecar_name(archive) not in live:
+            continue  # reported above for this run's archives
+        actual = live[archive] or hash_asset(archive)
+        recorded = parse_sidecar(read_sidecar(sidecar_name(archive)), 
sidecar_name(archive))
+        if actual != recorded:
+            problems.append(
+                f"{archive}: live digest {actual} does not match its checksum 
file ({recorded})"
+            )
+
+    for archive, before in sorted(snapshot.items()):
+        after = live.get(archive)
+        if archive not in live:
+            problems.append(f"{archive}: was live before the upload and is 
gone now")
+        elif before is not None and after != before:
+            problems.append(
+                f"{archive}: replaced during this run (was {before}, now 
{after}); "
+                "published archives must never change"
+            )
+    return problems
+
+
+def local_archives(artifacts: Path) -> dict[str, Path]:
+    """This run's archives, keyed by asset name, each checked against its 
sidecar."""
+    found: dict[str, Path] = {}
+    for path in sorted(artifacts.glob("release-*/rumdl-*")):
+        if not is_archive(path.name):
+            continue
+        if path.name in found:
+            raise ReleaseAssetError(f"{path.name}: built by more than one job")
+        sidecar = path.with_name(sidecar_name(path.name))
+        if not sidecar.is_file():
+            raise ReleaseAssetError(f"{path}: built without its 
{SIDECAR_SUFFIX} file")
+        recorded = parse_sidecar(sidecar.read_text(), str(sidecar))
+        actual = hashlib.sha256(path.read_bytes()).hexdigest()
+        if recorded != actual:
+            raise ReleaseAssetError(f"{sidecar}: records {recorded}, but the 
archive hashes to {actual}")
+        found[path.name] = path
+    if not found:
+        raise ReleaseAssetError(f"no release archives under 
{artifacts}/release-*/")
+    return found
+
+
+def _gh(*args: str) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(["gh", *args], capture_output=True, text=True)
+
+
+def fetch_live(tag: str) -> dict[str, str | None]:
+    """Live assets of the release for `tag`; empty when no such release 
exists."""
+    result = _gh("release", "view", tag, "--json", "assets")
+    if result.returncode != 0:
+        if result.stderr.strip() == "release not found":
+            return {}
+        raise ReleaseAssetError(f"gh release view {tag} failed: 
{result.stderr.strip()}")
+    assets = json.loads(result.stdout)["assets"]
+    return {a["name"]: normalize_digest(a.get("digest")) for a in assets}
+
+
+def _download(tag: str, name: str) -> bytes:
+    result = subprocess.run(
+        ["gh", "release", "download", tag, "--pattern", name, "--output", "-"],
+        capture_output=True,
+    )
+    if result.returncode != 0:
+        raise ReleaseAssetError(f"downloading {name} failed: 
{result.stderr.decode().strip()}")
+    return result.stdout
+
+
+def _write_output(key: str, value: str) -> None:
+    path = os.environ.get("GITHUB_OUTPUT")
+    if path:
+        with open(path, "a", encoding="utf-8") as fh:
+            fh.write(f"{key}={value}\n")
+
+
+def cmd_plan(args: argparse.Namespace) -> int:
+    archives = local_archives(Path(args.artifacts))
+    live = fetch_live(args.tag)
+    plan = make_plan(list(archives), live)
+
+    print(f"GitHub release {args.tag}: {len(live)} live assets")
+    for name in plan.upload:
+        print(f"  upload  {name}")
+    for name, digest in plan.kept.items():
+        print(f"  keep    {name} (sha256:{digest or 'unrecorded'}); this run's 
build is discarded")
+    for name in plan.live_only:
+        print(f"  note    {name} is live but this run did not build it; left 
as is")
+
+    Path(args.snapshot).write_text(json.dumps(plan.kept, indent=2, 
sort_keys=True) + "\n")
+    _write_output("new_archives", "true" if plan.new_archives else "false")
+    print(f"new_archives={'true' if plan.new_archives else 'false'}")
+    return 0
+
+
+def cmd_verify(args: argparse.Namespace) -> int:
+    archives = local_archives(Path(args.artifacts))
+    live = fetch_live(args.tag)
+    snapshot = json.loads(Path(args.snapshot).read_text())
+    problems = verify_release(
+        list(archives),
+        live,
+        snapshot,
+        read_sidecar=lambda name: _download(args.tag, name).decode("utf-8"),
+        hash_asset=lambda name: hashlib.sha256(_download(args.tag, 
name)).hexdigest(),
+    )
+    if problems:
+        for problem in problems:
+            print(f"::error::{problem}")
+        return 1
+    archive_count = sum(1 for name in live if is_archive(name))
+    print(
+        f"GitHub release {args.tag}: {archive_count} archives, each matching 
its checksum file; "
+        f"{len(snapshot)} previously published archive(s) unchanged"
+    )
+    return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+    sub = parser.add_subparsers(dest="command", required=True)
+    for name, func in (("plan", cmd_plan), ("verify", cmd_verify)):
+        p = sub.add_parser(name)
+        p.add_argument("--tag", required=True)
+        p.add_argument("--artifacts", required=True, help="directory holding 
release-*/ artifacts")
+        p.add_argument("--snapshot", required=True, help="JSON file recording 
the digests kept by `plan`")
+        p.set_defaults(func=func)
+    args = parser.parse_args(argv)
+    try:
+        return args.func(args)
+    except ReleaseAssetError as exc:
+        print(f"::error::{exc}")
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/rumdl-0.2.76/scripts/release_assets_test.py 
new/rumdl-0.2.77/scripts/release_assets_test.py
--- old/rumdl-0.2.76/scripts/release_assets_test.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/rumdl-0.2.77/scripts/release_assets_test.py     2026-09-23 
15:05:26.000000000 +0200
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+"""Regression tests for scripts/release_assets.py.
+
+The script is the only thing standing between a release workflow re-run and a
+silently replaced archive, and it runs only at release time, where a logic bug
+surfaces after the tag is pushed. These tests run it everywhere else.
+
+Hermetic: live release state is passed in as data and `gh` is never invoked.
+Run with:
+
+    python3 scripts/release_assets_test.py
+"""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import importlib.util
+import io
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+_SPEC = importlib.util.spec_from_file_location("release_assets", 
Path(__file__).with_name("release_assets.py"))
+ra = importlib.util.module_from_spec(_SPEC)
+sys.modules[_SPEC.name] = ra  # dataclasses resolve annotations through 
sys.modules
+_SPEC.loader.exec_module(ra)
+
+MAC = "rumdl-v1.2.3-aarch64-apple-darwin.tar.gz"
+WIN = "rumdl-v1.2.3-x86_64-pc-windows-msvc.zip"
+LINUX = "rumdl-v1.2.3-x86_64-unknown-linux-musl.tar.gz"
+
+# The real v0.2.76 aarch64-apple-darwin digests from issue #909: the first
+# upload, and the rebuild that replaced it.
+ORIGINAL = "28f9af1569fac063af2f5adcadcdcfc23ba7eac4ceadd975bd381612e3ed24a6"
+REBUILT = "10ec95ee46e1d3f67560250db97725681a5fa4bc488f383615cc54b06c4bdbc7"
+OTHER = "f7efe829339cd70c13a5b9d0f334094e4c1c30f935fa143edd4d5d8839746655"
+
+
+def sidecar(name: str) -> str:
+    return name + ".sha256"
+
+
+def unix_sidecar(digest: str, name: str) -> str:
+    return f"{digest}  {name}\n"
+
+
+class ParseSidecarTest(unittest.TestCase):
+    def test_sha256sum_format(self):
+        self.assertEqual(ra.parse_sidecar(unix_sidecar(ORIGINAL, MAC), "x"), 
ORIGINAL)
+
+    def test_powershell_format_is_uppercase_with_crlf(self):
+        self.assertEqual(ra.parse_sidecar(OTHER.upper() + "\r\n", "x"), OTHER)
+
+    def test_empty_file_is_an_error(self):
+        with self.assertRaisesRegex(ra.ReleaseAssetError, "empty"):
+            ra.parse_sidecar("\n", "x")
+
+    def test_non_digest_is_an_error_not_a_guess(self):
+        for text in ("Not Found\n", ORIGINAL[:-1] + "  f\n", "sha256:" + 
ORIGINAL):
+            with self.subTest(text=text), 
self.assertRaises(ra.ReleaseAssetError):
+                ra.parse_sidecar(text, "x")
+
+
+class NormalizeDigestTest(unittest.TestCase):
+    def test_github_digest(self):
+        self.assertEqual(ra.normalize_digest("sha256:" + ORIGINAL), ORIGINAL)
+
+    def test_missing_digest_stays_missing(self):
+        self.assertIsNone(ra.normalize_digest(None))
+        self.assertIsNone(ra.normalize_digest(""))
+
+    def test_unknown_algorithm_is_an_error(self):
+        with self.assertRaises(ra.ReleaseAssetError):
+            ra.normalize_digest("sha512:" + ORIGINAL)
+
+
+class MakePlanTest(unittest.TestCase):
+    def test_first_publish_uploads_everything(self):
+        plan = ra.make_plan([MAC, WIN], {})
+        self.assertEqual(plan.upload, [MAC, WIN])
+        self.assertEqual(plan.kept, {})
+        self.assertTrue(plan.new_archives)
+
+    def test_backfill_over_a_published_release_uploads_nothing(self):
+        # The #909 run: every archive already live, so the rebuild is discarded
+        # and consumers of the archives are not re-notified.
+        live = {MAC: ORIGINAL, sidecar(MAC): None, WIN: OTHER, sidecar(WIN): 
None}
+        plan = ra.make_plan([MAC, WIN], live)
+        self.assertEqual(plan.upload, [])
+        self.assertEqual(plan.kept, {MAC: ORIGINAL, WIN: OTHER})
+        self.assertFalse(plan.new_archives)
+
+    def test_partial_release_uploads_only_the_missing_platforms(self):
+        live = {MAC: ORIGINAL, sidecar(MAC): None}
+        plan = ra.make_plan([MAC, WIN], live)
+        self.assertEqual(plan.upload, [WIN])
+        self.assertEqual(plan.kept, {MAC: ORIGINAL})
+        self.assertTrue(plan.new_archives)
+
+    def test_archive_without_its_sidecar_is_refused(self):
+        with self.assertRaisesRegex(ra.ReleaseAssetError, MAC.replace(".", 
r"\.")):
+            ra.make_plan([MAC], {MAC: ORIGINAL})
+
+    def test_sidecar_without_its_archive_is_refused(self):
+        with self.assertRaisesRegex(ra.ReleaseAssetError, "without their 
archive/checksum partner"):
+            ra.make_plan([MAC], {sidecar(MAC): None})
+
+    def test_live_archive_this_run_did_not_build_is_left_alone(self):
+        live = {LINUX: OTHER, sidecar(LINUX): None}
+        plan = ra.make_plan([MAC], live)
+        self.assertEqual(plan.upload, [MAC])
+        self.assertEqual(plan.live_only, [LINUX])
+
+    def test_sidecars_are_not_mistaken_for_archives(self):
+        self.assertFalse(ra.is_archive(sidecar(MAC)))
+        self.assertFalse(ra.is_archive(sidecar(WIN)))
+
+
+class VerifyReleaseTest(unittest.TestCase):
+    def verify(self, local, live, snapshot, sidecars, hashes=None):
+        def read_sidecar(name):
+            return sidecars[name]
+
+        def hash_asset(name):
+            if hashes is None or name not in hashes:
+                raise AssertionError(f"unexpected download of {name}")
+            return hashes[name]
+
+        return ra.verify_release(local, live, snapshot, read_sidecar, 
hash_asset)
+
+    def test_untouched_backfill_is_clean(self):
+        live = {MAC: ORIGINAL, sidecar(MAC): None}
+        sidecars = {sidecar(MAC): unix_sidecar(ORIGINAL, MAC)}
+        self.assertEqual(self.verify([MAC], live, {MAC: ORIGINAL}, sidecars), 
[])
+
+    def test_replaced_archive_is_the_909_failure(self):
+        # What overwrite_files: true did to v0.2.76: archive and sidecar both
+        # replaced, so they agree with each other. Only the snapshot catches 
it.
+        live = {MAC: REBUILT, sidecar(MAC): None}
+        sidecars = {sidecar(MAC): unix_sidecar(REBUILT, MAC)}
+        problems = self.verify([MAC], live, {MAC: ORIGINAL}, sidecars)
+        self.assertEqual(len(problems), 1)
+        self.assertIn("replaced during this run", problems[0])
+        self.assertIn(ORIGINAL, problems[0])
+        self.assertIn(REBUILT, problems[0])
+
+    def test_archive_and_sidecar_that_disagree(self):
+        live = {MAC: ORIGINAL, sidecar(MAC): None}
+        sidecars = {sidecar(MAC): unix_sidecar(REBUILT, MAC)}
+        problems = self.verify([MAC], live, {}, sidecars)
+        self.assertEqual(len(problems), 1)
+        self.assertIn("does not match its checksum file", problems[0])
+
+    def test_windows_sidecar_matches_case_insensitively(self):
+        live = {WIN: OTHER, sidecar(WIN): None}
+        sidecars = {sidecar(WIN): OTHER.upper() + "\r\n"}
+        self.assertEqual(self.verify([WIN], live, {}, sidecars), [])
+
+    def test_archive_missing_after_upload(self):
+        problems = self.verify([MAC], {}, {}, {})
+        self.assertEqual(
+            problems,
+            [f"{MAC}: not on the release after the upload", f"{sidecar(MAC)}: 
not on the release after the upload"],
+        )
+
+    def test_previously_live_archive_that_vanished(self):
+        live = {WIN: OTHER, sidecar(WIN): None}
+        sidecars = {sidecar(WIN): unix_sidecar(OTHER, WIN)}
+        problems = self.verify([WIN], live, {MAC: ORIGINAL}, sidecars)
+        self.assertEqual(problems, [f"{MAC}: was live before the upload and is 
gone now"])
+
+    def test_archive_without_recorded_digest_is_downloaded_and_hashed(self):
+        live = {MAC: None, sidecar(MAC): None}
+        sidecars = {sidecar(MAC): unix_sidecar(ORIGINAL, MAC)}
+        self.assertEqual(self.verify([MAC], live, {}, sidecars, hashes={MAC: 
ORIGINAL}), [])
+        problems = self.verify([MAC], live, {}, sidecars, hashes={MAC: 
REBUILT})
+        self.assertEqual(len(problems), 1)
+        self.assertIn("does not match its checksum file", problems[0])
+
+
+class LocalArchivesTest(unittest.TestCase):
+    def build(self, root: Path, job: str, name: str, data: bytes, recorded: 
str | None = None):
+        d = root / job
+        d.mkdir(parents=True, exist_ok=True)
+        (d / name).write_bytes(data)
+        digest = recorded or hashlib.sha256(data).hexdigest()
+        (d / sidecar(name)).write_text(unix_sidecar(digest, name))
+
+    def test_collects_archives_across_jobs(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            root = Path(tmp)
+            self.build(root, "release-aarch64-apple-darwin", MAC, b"mac")
+            self.build(root, "release-x86_64-pc-windows-msvc", WIN, b"win")
+            (root / "wheel-x").mkdir()
+            (root / "wheel-x" / "rumdl-1.2.3.tar.gz").write_bytes(b"not a 
release archive")
+            self.assertEqual(sorted(ra.local_archives(root)), [MAC, WIN])
+
+    def test_archive_that_does_not_match_its_own_sidecar(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            root = Path(tmp)
+            self.build(root, "release-aarch64-apple-darwin", MAC, b"mac", 
recorded=ORIGINAL)
+            with self.assertRaisesRegex(ra.ReleaseAssetError, "hashes to"):
+                ra.local_archives(root)
+
+    def test_archive_built_without_a_sidecar(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            d = Path(tmp) / "release-aarch64-apple-darwin"
+            d.mkdir()
+            (d / MAC).write_bytes(b"mac")
+            with self.assertRaisesRegex(ra.ReleaseAssetError, "without its"):
+                ra.local_archives(Path(tmp))
+
+    def test_no_archives_is_an_error(self):
+        with tempfile.TemporaryDirectory() as tmp, 
self.assertRaisesRegex(ra.ReleaseAssetError, "no release archives"):
+            ra.local_archives(Path(tmp))
+
+
+class CommandTest(unittest.TestCase):
+    """The plan -> upload -> verify sequence through the real entry points."""
+
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self.tmp.name)
+        self.artifacts = self.root / "artifacts"
+        d = self.artifacts / "release-aarch64-apple-darwin"
+        d.mkdir(parents=True)
+        self.rebuilt = b"rebuilt bytes"
+        (d / MAC).write_bytes(self.rebuilt)
+        (d / 
sidecar(MAC)).write_text(unix_sidecar(hashlib.sha256(self.rebuilt).hexdigest(), 
MAC))
+        self.snapshot = self.root / "snapshot.json"
+        self.output = self.root / "github_output"
+        self.output.write_text("")
+        self.live = {}
+        self.sidecars = {}
+        self._orig = (ra.fetch_live, ra._download)
+        ra.fetch_live = lambda tag: dict(self.live)
+        ra._download = lambda tag, name: self.sidecars[name].encode()
+        self._env = ra.os.environ.get("GITHUB_OUTPUT")
+        ra.os.environ["GITHUB_OUTPUT"] = str(self.output)
+
+    def tearDown(self):
+        ra.fetch_live, ra._download = self._orig
+        if self._env is None:
+            ra.os.environ.pop("GITHUB_OUTPUT", None)
+        else:
+            ra.os.environ["GITHUB_OUTPUT"] = self._env
+        self.tmp.cleanup()
+
+    def run_cmd(self, command):
+        args = [command, "--tag", "v1.2.3", "--artifacts", 
str(self.artifacts), "--snapshot", str(self.snapshot)]
+        self.stdout = io.StringIO()
+        with contextlib.redirect_stdout(self.stdout):
+            return ra.main(args)
+
+    def test_backfill_keeps_the_original_and_verifies(self):
+        self.live = {MAC: ORIGINAL, sidecar(MAC): None}
+        self.sidecars = {sidecar(MAC): unix_sidecar(ORIGINAL, MAC)}
+        self.assertEqual(self.run_cmd("plan"), 0)
+        self.assertEqual(self.output.read_text(), "new_archives=false\n")
+        self.assertEqual(json.loads(self.snapshot.read_text()), {MAC: 
ORIGINAL})
+        # overwrite_files: false leaves the release untouched.
+        self.assertEqual(self.run_cmd("verify"), 0)
+
+    def test_verify_fails_when_the_upload_overwrote(self):
+        self.live = {MAC: ORIGINAL, sidecar(MAC): None}
+        self.assertEqual(self.run_cmd("plan"), 0)
+        rebuilt = hashlib.sha256(self.rebuilt).hexdigest()
+        self.live = {MAC: rebuilt, sidecar(MAC): None}
+        self.sidecars = {sidecar(MAC): unix_sidecar(rebuilt, MAC)}
+        self.assertEqual(self.run_cmd("verify"), 1)
+        self.assertIn(f"::error::{MAC}: replaced during this run (was 
{ORIGINAL}, now {rebuilt})", self.stdout.getvalue())
+
+    def test_first_publish_signals_new_archives(self):
+        self.assertEqual(self.run_cmd("plan"), 0)
+        self.assertEqual(self.output.read_text(), "new_archives=true\n")
+        self.assertEqual(json.loads(self.snapshot.read_text()), {})
+        rebuilt = hashlib.sha256(self.rebuilt).hexdigest()
+        self.live = {MAC: rebuilt, sidecar(MAC): None}
+        self.sidecars = {sidecar(MAC): unix_sidecar(rebuilt, MAC)}
+        self.assertEqual(self.run_cmd("verify"), 0)
+
+    def test_half_published_pair_fails_plan_before_any_upload(self):
+        self.live = {MAC: ORIGINAL}
+        self.assertEqual(self.run_cmd("plan"), 1)
+        self.assertIn("::error::these assets are live without their 
archive/checksum partner", self.stdout.getvalue())
+        self.assertFalse(self.snapshot.exists())
+        self.assertEqual(self.output.read_text(), "")
+
+
+if __name__ == "__main__":
+    unittest.main()

++++++ rumdl.obsinfo ++++++
--- /var/tmp/diff_new_pack.DWpjgS/_old  2026-09-24 23:00:32.608965965 +0200
+++ /var/tmp/diff_new_pack.DWpjgS/_new  2026-09-24 23:00:32.612966133 +0200
@@ -1,5 +1,5 @@
 name: rumdl
-version: 0.2.76
-mtime: 1790124925
-commit: 389e12baa4571b7640bb9f0687cf55946da89e4f
+version: 0.2.77
+mtime: 1790168726
+commit: bb84a49a85625b31ecf6c3e58b7bca7c75fbfefb
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.rumdl.new.383539/vendor.tar.zst differ: char 7, 
line 1

Reply via email to