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
