From 91a695d5a9b0a20f52dc14166044752a3dc8abae Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 8 Oct 2026 13:06:28 +0800 Subject: [PATCH 1/3] fix(ci): publish absolute tarball paths and recover verified tag assets --- .github/workflows/release.yml | 112 +++++++++++++++- scripts/validate_release_recovery.py | 184 +++++++++++++++++++++++++++ tests/test_release_packaging.py | 10 +- tests/test_release_recovery.py | 177 ++++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 scripts/validate_release_recovery.py create mode 100644 tests/test_release_recovery.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0266409..3595aa3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,17 @@ on: tags: - "v*" workflow_dispatch: + inputs: + recovery_run_id: + description: "Optional: recover npm publication from a completed tag release run" + required: false + type: string + default: "" + recovery_tag: + description: "Existing release tag whose verified assets should be published" + required: false + type: string + default: "" permissions: contents: read @@ -17,7 +28,7 @@ jobs: verify-registry-token: name: verify crates.io token configuration runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.recovery_run_id == '' steps: - name: Validate crates.io token configuration env: @@ -34,6 +45,7 @@ jobs: test: name: test (linux) + if: github.event_name != 'workflow_dispatch' || inputs.recovery_run_id == '' runs-on: ubuntu-latest outputs: release-version: ${{ steps.version.outputs.release-version }} @@ -82,7 +94,9 @@ jobs: cargo test --manifest-path wasm/Cargo.toml --all-targets --release --locked - name: release packaging tests - run: python3 tests/test_release_packaging.py + run: | + python3 tests/test_release_packaging.py + python3 tests/test_release_recovery.py - name: cargo build (bin) run: cargo build --release --bin recommend_cli @@ -354,8 +368,9 @@ jobs: esac # Trusted publishing requires npm >= 11.5.1 and Node >= 22.14. npm install -g npm@latest + NPM_PACKAGE=$(realpath "release/allium-deck-wasm-v${VERSION}.tgz") set +e - npm publish --provenance --access public "release/allium-deck-wasm-v${VERSION}.tgz" + npm publish --provenance --access public "$NPM_PACKAGE" PUBLISH_STATUS=$? set -e if node scripts/verify_npm_package.mjs \ @@ -495,3 +510,94 @@ jobs: artifacts/allium-deck-wasm-*.tgz artifacts/SHA256SUMS fail_on_unmatched_files: true + + recover-npm: + name: recover npm from verified tag assets + if: github.event_name == 'workflow_dispatch' && inputs.recovery_run_id != '' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + id-token: write + env: + RELEASE_TAG: ${{ inputs.recovery_tag }} + SOURCE_RUN_ID: ${{ inputs.recovery_run_id }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Resolve the immutable source tag and release run + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import os, re + assert re.fullmatch(r"[0-9]+", os.environ["SOURCE_RUN_ID"]), "invalid run ID" + assert re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", os.environ["RELEASE_TAG"]), "invalid tag" + PY + SOURCE_SHA=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + echo "SOURCE_SHA=$SOURCE_SHA" >> "$GITHUB_ENV" + echo "VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID" > "$RUNNER_TEMP/source-run.json" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/artifacts?per_page=100" > "$RUNNER_TEMP/source-artifacts.json" + # Publication recovery must not substitute different package sources. + git diff --exit-code "$SOURCE_SHA" HEAD -- Cargo.toml Cargo.lock src wasm/Cargo.toml wasm/Cargo.lock wasm/src wasm/README.md README.md README.en.md LICENSE-MIT LICENSE-APACHE + + - name: Download the original release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-assets + run-id: ${{ inputs.recovery_run_id }} + github-token: ${{ github.token }} + path: release + + - name: Validate the complete source and artifact chain + run: | + python3 scripts/validate_release_recovery.py \ + --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" \ + --source-sha "$SOURCE_SHA" --run-json "$RUNNER_TEMP/source-run.json" \ + --artifacts-json "$RUNNER_TEMP/source-artifacts.json" --asset-dir release \ + | tee recovery-evidence.json + + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Publish only the original tested tarball + run: | + NPM_PACKAGE=$(realpath "release/allium-deck-wasm-v${VERSION}.tgz") + set +e + node scripts/verify_npm_package.mjs \ + --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ + --tarball "$NPM_PACKAGE" --attempts 3 --delay-ms 2000 + LOOKUP_STATUS=$? + set -e + case "$LOOKUP_STATUS" in + 0) exit 0 ;; + 3) ;; + *) exit "$LOOKUP_STATUS" ;; + esac + npm install -g npm@latest + set +e + npm publish --provenance --access public "$NPM_PACKAGE" + PUBLISH_STATUS=$? + set -e + if node scripts/verify_npm_package.mjs \ + --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ + --tarball "$NPM_PACKAGE" --attempts 6 --delay-ms 3000; then + if [ "$PUBLISH_STATUS" -ne 0 ]; then + echo "npm publish returned ${PUBLISH_STATUS}, but the tested tarball is present" + fi + exit 0 + fi + exit 1 + + - name: Save recovery verification + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: npm-recovery-evidence + path: recovery-evidence.json + if-no-files-found: ignore diff --git a/scripts/validate_release_recovery.py b/scripts/validate_release_recovery.py new file mode 100644 index 0000000..2882d2b --- /dev/null +++ b/scripts/validate_release_recovery.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Read-only validation of original tag artifacts before recovering publication.""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import re +import stat +import sys +import tarfile +import zipfile +from pathlib import Path, PurePosixPath + + +REPOSITORY = "empty-sekai/allium-deck" +PACKAGE = "@empty-sekai/allium-deck-wasm" +MAX_MEMBER_BYTES = 100 * 1024 * 1024 + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def file_hash(path: Path, algorithm: str = "sha256") -> str: + with path.open("rb") as handle: + return hashlib.file_digest(handle, algorithm).hexdigest() + + +def safe_name(name: str) -> bool: + path = PurePosixPath(name) + return bool(name) and not path.is_absolute() and ".." not in path.parts and "\\" not in name + + +def release_names(version: str) -> set[str]: + return { + *(f"recommend_cli-v{version}-{platform}" for platform in ( + "linux-x86_64", "linux-x86_64-musl", "linux-aarch64", + "windows-x86_64.exe", "macos-aarch64", + )), + f"allium-deck-wasm-v{version}-cn.zip", + f"allium-deck-wasm-v{version}.tgz", + } + + +def validate_run(run: dict, artifacts: dict, repository: str, tag: str, revision: str) -> dict: + require(run.get("repository", {}).get("full_name") == repository, "source run repository mismatch") + require(run.get("event") == "push", "source run must be a push event") + require(run.get("path") == ".github/workflows/release.yml", "source workflow path mismatch") + require(run.get("head_branch") == tag, "source run tag mismatch") + require(run.get("head_sha") == revision, "source run revision mismatch") + require(run.get("status") == "completed", "source run has not completed") + require(run.get("conclusion") in ("success", "failure"), "source run conclusion cannot be recovered") + require(type(run.get("id")) is int and run["id"] > 0, "source run ID is missing") + repository_id = run.get("repository", {}).get("id") + require(type(repository_id) is int and repository_id > 0, "source repository ID is missing") + rows = artifacts.get("artifacts") + require(isinstance(rows, list), "artifact list is missing") + selected = [row for row in rows if isinstance(row, dict) and row.get("name") == "release-assets"] + require(len(selected) == 1, "expected exactly one release-assets artifact") + artifact = selected[0] + require(artifact.get("expired") is False, "release-assets is expired or expiration state is missing") + require(type(artifact.get("id")) is int and artifact["id"] > 0, "artifact ID is missing") + require(bool(re.fullmatch(r"sha256:[a-f0-9]{64}", str(artifact.get("digest", "")))), "artifact digest is missing or invalid") + source = artifact.get("workflow_run") + require(isinstance(source, dict), "artifact workflow-run identity is missing") + require(source.get("id") == run["id"], "artifact belongs to a different run") + require(source.get("head_sha") == revision, "artifact revision mismatch") + require(source.get("head_branch") == tag, "artifact tag mismatch") + require(source.get("repository_id") == repository_id, "artifact repository mismatch") + require(source.get("head_repository_id") == repository_id, "artifact head repository mismatch") + return artifact + + +def validate_checksums(directory: Path, version: str) -> None: + expected = release_names(version) + actual = {entry.name for entry in directory.iterdir()} + require(actual == expected | {"SHA256SUMS"}, "release asset filenames do not match the complete release set") + sums = directory / "SHA256SUMS" + require(sums.is_file() and not sums.is_symlink(), "SHA256SUMS must be a regular file") + recorded: dict[str, str] = {} + for line in sums.read_text(encoding="utf-8").splitlines(): + match = re.fullmatch(r"([a-fA-F0-9]{64}) [ *](.+)", line) + require(match is not None, "invalid SHA256SUMS row") + digest, name = match.groups() + require(name in expected and name not in recorded, "unexpected or duplicate SHA256SUMS filename") + recorded[name] = digest.lower() + require(set(recorded) == expected, "SHA256SUMS does not cover every release asset") + for name in expected: + path = directory / name + require(path.is_file() and not path.is_symlink() and path.stat().st_size > 0, + f"release asset is not a nonempty regular file: {name}") + require(file_hash(path) == recorded[name], f"release checksum mismatch: {name}") + + +def read_browser_bundle(path: Path, version: str, repository: str, revision: str) -> dict[str, bytes]: + with zipfile.ZipFile(path) as archive: + entries = archive.infolist() + names = [entry.filename for entry in entries] + require(len(names) == len(set(names)), "duplicate ZIP entry") + require(all(safe_name(name) and PurePosixPath(name).name == name for name in names), "invalid browser bundle entry path") + require(all(not entry.is_dir() and not stat.S_ISLNK(entry.external_attr >> 16) + and entry.file_size <= MAX_MEMBER_BYTES for entry in entries), "invalid browser bundle member") + content = {name: archive.read(name) for name in names} + require("manifest.json" in content, "browser manifest is missing") + manifest = json.loads(content["manifest.json"]) + require(manifest.get("name") == "allium-deck-wasm", "browser manifest package mismatch") + require(manifest.get("version") == version, "browser manifest version mismatch") + require(manifest.get("source") == {"repository": repository, "revision": revision}, "browser manifest source mismatch") + require(manifest.get("entrypoint") == "allium_deck.js" and manifest.get("wasm") == "allium_deck_bg.wasm", "browser manifest entrypoint mismatch") + files = manifest.get("files") + require(isinstance(files, list), "browser manifest file list is missing") + declared: set[str] = set() + for entry in files: + require(isinstance(entry, dict), "invalid browser manifest file row") + name = entry.get("name") + require(isinstance(name, str) and name not in declared and name in content and name != "manifest.json", "duplicate or missing browser manifest file") + declared.add(name) + require(type(entry.get("bytes")) is int and entry["bytes"] == len(content[name]), f"browser file size mismatch: {name}") + require(entry.get("sha256") == hashlib.sha256(content[name]).hexdigest(), f"browser file checksum mismatch: {name}") + require(declared == set(content) - {"manifest.json"}, "browser manifest does not cover every archive file") + return content + + +def read_npm_tarball(path: Path) -> dict[str, bytes]: + with tarfile.open(path, "r:gz") as archive: + entries = archive.getmembers() + names = [entry.name for entry in entries] + require(len(names) == len(set(names)), "duplicate npm tarball entry") + require(all(safe_name(name) and name.startswith("package/") for name in names), "invalid npm tarball entry path") + require(all(entry.isfile() and entry.size <= MAX_MEMBER_BYTES for entry in entries), "npm tarball contains a non-regular or oversized entry") + return {entry.name.removeprefix("package/"): archive.extractfile(entry).read() for entry in entries} + + +def validate_recovery(*, tag: str, source_sha: str, run: dict, artifacts: dict, + asset_dir: Path, repository: str = REPOSITORY) -> dict: + require(repository == REPOSITORY, "unsupported recovery repository") + require(bool(re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", tag)), "invalid release tag") + require(bool(re.fullmatch(r"[a-f0-9]{40}", source_sha)), "source-sha must be a full commit hash") + version = tag[1:] + artifact = validate_run(run, artifacts, repository, tag, source_sha) + validate_checksums(asset_dir, version) + browser = read_browser_bundle(asset_dir / f"allium-deck-wasm-{tag}-cn.zip", version, repository, source_sha) + tarball = asset_dir / f"allium-deck-wasm-{tag}.tgz" + npm = read_npm_tarball(tarball) + for name in ("package.json", "allium_deck.js", "allium_deck_bg.wasm"): + require(name in browser and name in npm and browser[name] == npm[name], f"ZIP/npm content mismatch: {name}") + metadata = json.loads(npm["package.json"]) + require(metadata.get("name") == PACKAGE and metadata.get("version") == version, "npm package identity mismatch") + require(metadata.get("repository") == {"type": "git", "url": f"git+https://github.com/{repository}.git"}, "npm package repository mismatch") + with tarball.open("rb") as handle: + sri = "sha512-" + base64.b64encode(hashlib.file_digest(handle, "sha512").digest()).decode("ascii") + return { + "repository": repository, "tag": tag, "revision": source_sha, "version": version, + "runId": run["id"], "artifactId": artifact["id"], "artifactDigest": artifact["digest"], + "wasmSha256": hashlib.sha256(npm["allium_deck_bg.wasm"]).hexdigest(), + "tgzSha256": file_hash(tarball), "tgzSRI": sri, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--run-json", required=True, type=Path) + parser.add_argument("--artifacts-json", required=True, type=Path) + parser.add_argument("--asset-dir", required=True, type=Path) + parser.add_argument("--repository", default=REPOSITORY, choices=[REPOSITORY]) + args = parser.parse_args() + result = validate_recovery(tag=args.tag, source_sha=args.source_sha, + run=json.loads(args.run_json.read_text(encoding="utf-8-sig")), + artifacts=json.loads(args.artifacts_json.read_text(encoding="utf-8-sig")), + asset_dir=args.asset_dir, repository=args.repository) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index f34e903..d161504 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -133,7 +133,8 @@ def test_npm_publish_checks_integrity_before_and_after_upload(self) -> None: def test_manual_preflight_builds_all_assets_without_publication(self) -> None: workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") jobs = dict(re.findall(r"^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:\n|\Z)", workflow, re.M | re.S)) - for name in ["test", "build-binaries", "build-wasm-release", "release-assets"]: + self.assertIn("if: github.event_name != 'workflow_dispatch' || inputs.recovery_run_id == ''", jobs["test"]) + for name in ["build-binaries", "build-wasm-release", "release-assets"]: self.assertNotRegex(jobs[name], re.compile(r"^ if:", re.M), name) for name in ["publish-npm", "publish-crate", "release"]: self.assertIn("if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')", jobs[name]) @@ -142,7 +143,12 @@ def test_manual_preflight_builds_all_assets_without_publication(self) -> None: self.assertNotIn("id-token: write", jobs["build-wasm-release"]) self.assertIn("needs: [test, build-binaries, build-wasm-release]", jobs["release-assets"]) self.assertIn("pattern: release-build-*", jobs["release-assets"]) - self.assertIn('npm publish --provenance --access public "release/allium-deck-wasm-v${VERSION}.tgz"', jobs["publish-npm"]) + self.assertIn('NPM_PACKAGE=$(realpath "release/allium-deck-wasm-v${VERSION}.tgz")', jobs["publish-npm"]) + self.assertIn('npm publish --provenance --access public "$NPM_PACKAGE"', jobs["publish-npm"]) + self.assertIn("if: github.event_name == 'workflow_dispatch' && inputs.recovery_run_id != ''", jobs["recover-npm"]) + self.assertIn("scripts/validate_release_recovery.py", jobs["recover-npm"]) + self.assertIn("run-id: ${{ inputs.recovery_run_id }}", jobs["recover-npm"]) + self.assertIn('npm publish --provenance --access public "$NPM_PACKAGE"', jobs["recover-npm"]) self.assertIn("artifacts/SHA256SUMS", jobs["release"]) def test_asset_names_use_version_instead_of_dispatch_branch(self) -> None: diff --git a/tests/test_release_recovery.py b/tests/test_release_recovery.py new file mode 100644 index 0000000..ea25b42 --- /dev/null +++ b/tests/test_release_recovery.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import io +import json +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("recovery", ROOT / "scripts/validate_release_recovery.py") +recovery = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(recovery) + + +class ReleaseRecoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.sha = "a" * 40 + self.run = { + "id": 100, "event": "push", "path": ".github/workflows/release.yml", + "head_branch": "v0.1.0", "head_sha": self.sha, + "status": "completed", "conclusion": "failure", + "repository": {"id": 200, "full_name": recovery.REPOSITORY}, + } + self.artifact = { + "id": 300, "name": "release-assets", "expired": False, + "digest": "sha256:" + "b" * 64, + "workflow_run": {"id": 100, "head_sha": self.sha, "head_branch": "v0.1.0", + "repository_id": 200, "head_repository_id": 200}, + } + self.artifacts = {"artifacts": [self.artifact]} + package = {"name": recovery.PACKAGE, "version": "0.1.0", + "repository": {"type": "git", "url": f"git+https://github.com/{recovery.REPOSITORY}.git"}} + self.browser = {"package.json": json.dumps(package).encode(), + "allium_deck.js": b"export default function initialize() {}", + "allium_deck_bg.wasm": b"\0asm-test-bytes"} + self.manifest = {"name": "allium-deck-wasm", "version": "0.1.0", + "source": {"repository": recovery.REPOSITORY, "revision": self.sha}, + "entrypoint": "allium_deck.js", "wasm": "allium_deck_bg.wasm", + "files": [{"name": name, "bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest()} + for name, content in self.browser.items()]} + for name in recovery.release_names("0.1.0"): + if name.startswith("recommend_cli-"): + (self.directory / name).write_bytes(b"native executable fixture") + self.write_zip() + self.write_tar(self.browser) + self.write_sums() + + def write_zip(self) -> None: + with zipfile.ZipFile(self.directory / "allium-deck-wasm-v0.1.0-cn.zip", "w") as archive: + for name, content in self.browser.items(): + archive.writestr(name, content) + archive.writestr("manifest.json", json.dumps(self.manifest)) + + def write_tar(self, files: dict[str, bytes]) -> None: + with tarfile.open(self.directory / "allium-deck-wasm-v0.1.0.tgz", "w:gz") as archive: + for name, content in files.items(): + member = tarfile.TarInfo("package/" + name) + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + + def write_sums(self) -> None: + rows = [f"{recovery.file_hash(self.directory / name)} {name}\n" + for name in sorted(recovery.release_names("0.1.0"))] + (self.directory / "SHA256SUMS").write_text("".join(rows), encoding="utf-8") + + def validate(self) -> dict: + return recovery.validate_recovery(tag="v0.1.0", source_sha=self.sha, + run=self.run, artifacts=self.artifacts, asset_dir=self.directory) + + def test_valid_failed_or_successful_tag_build_is_read_only(self) -> None: + before = {path.name: path.read_bytes() for path in self.directory.iterdir()} + result = self.validate() + self.assertEqual(result["artifactId"], 300) + self.assertEqual(result["revision"], self.sha) + self.assertEqual(result["wasmSha256"], hashlib.sha256(self.browser["allium_deck_bg.wasm"]).hexdigest()) + self.assertTrue(result["tgzSRI"].startswith("sha512-")) + self.run["conclusion"] = "success" + self.assertEqual(self.validate(), result) + self.assertEqual(before, {path.name: path.read_bytes() for path in self.directory.iterdir()}) + + def test_unrelated_or_incomplete_workflow_runs_are_rejected(self) -> None: + original = copy.deepcopy(self.run) + changes = [ + ("head_sha", "c" * 40), ("head_branch", "v0.0.15"), ("event", "workflow_dispatch"), + ("path", ".github/workflows/ci.yml"), ("status", "in_progress"), + ("conclusion", "cancelled"), ("repository", {"id": 200, "full_name": "other/repo"}), + ] + for key, value in changes: + with self.subTest(key=key): + self.run = {**original, key: value} + with self.assertRaises(ValueError): + self.validate() + + def test_artifact_must_be_unique_unexpired_and_bound_to_the_original_run(self) -> None: + original = copy.deepcopy(self.artifact) + variants = [[], [original, copy.deepcopy(original)], [{**original, "expired": True}], + [{**original, "digest": None}], [{**original, "workflow_run": None}]] + for field, value in [("head_sha", "d" * 40), ("id", 999), ("head_branch", "main"), + ("repository_id", 999), ("head_repository_id", 999)]: + modified = copy.deepcopy(original) + modified["workflow_run"][field] = value + variants.append([modified]) + for entries in variants: + with self.subTest(entries=entries): + self.artifacts = {"artifacts": entries} + with self.assertRaises(ValueError): + self.validate() + + def test_all_seven_checksums_are_required_and_never_rewritten(self) -> None: + sums = self.directory / "SHA256SUMS" + original = sums.read_bytes() + extra = self.directory / "unpacked" + extra.mkdir() + with self.assertRaisesRegex(ValueError, "filenames"): + self.validate() + extra.rmdir() + for content in [b"".join(original.splitlines(keepends=True)[1:]), original + original.splitlines(keepends=True)[0]]: + sums.write_bytes(content) + with self.assertRaises(ValueError): + self.validate() + self.assertEqual(sums.read_bytes(), content) + sums.write_bytes(original) + native = self.directory / "recommend_cli-v0.1.0-linux-x86_64" + native.write_bytes(b"changed native executable") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + self.validate() + self.assertEqual(sums.read_bytes(), original) + + def test_archive_source_and_internal_hashes_are_checked_after_outer_checksums(self) -> None: + original = copy.deepcopy(self.manifest) + variants = [] + for key, value in [("source", {"repository": recovery.REPOSITORY, "revision": "e" * 40}), + ("version", "0.0.15")]: + variants.append({**original, key: value}) + bad_hash = copy.deepcopy(original) + bad_hash["files"][0]["sha256"] = "0" * 64 + variants.append(bad_hash) + for manifest in variants: + with self.subTest(manifest=manifest): + self.manifest = manifest + self.write_zip() + self.write_sums() + with self.assertRaises(ValueError): + self.validate() + + def test_tgz_bytes_must_equal_zip_and_metadata_identity_must_match(self) -> None: + for name in ["allium_deck_bg.wasm", "allium_deck.js", "package.json"]: + with self.subTest(name=name): + modified = {**self.browser, name: b"different"} + self.write_tar(modified) + self.write_sums() + with self.assertRaisesRegex(ValueError, "ZIP/npm content mismatch"): + self.validate() + wrong_package = json.loads(self.browser["package.json"]) + wrong_package["name"] = "wrong-package" + self.browser["package.json"] = json.dumps(wrong_package).encode() + row = next(row for row in self.manifest["files"] if row["name"] == "package.json") + row.update(bytes=len(self.browser["package.json"]), sha256=hashlib.sha256(self.browser["package.json"]).hexdigest()) + self.write_zip() + self.write_tar(self.browser) + self.write_sums() + with self.assertRaisesRegex(ValueError, "npm package identity mismatch"): + self.validate() + + +if __name__ == "__main__": + unittest.main() From b8ca2e813761186a2c9fc9c6711d22ef77a5e36f Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 8 Oct 2026 13:09:59 +0800 Subject: [PATCH 2/3] fix(ci): allow registry processing after accepted publication --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3595aa3..4daf5db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -376,7 +376,7 @@ jobs: if node scripts/verify_npm_package.mjs \ --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ --tarball "release/allium-deck-wasm-v${VERSION}.tgz" \ - --attempts 6 --delay-ms 3000 --timeout-ms 5000; then + --attempts 36 --delay-ms 5000 --timeout-ms 5000; then if [ "$PUBLISH_STATUS" -ne 0 ]; then echo "npm publish returned ${PUBLISH_STATUS}, but the registry has the tested tarball" fi @@ -586,7 +586,7 @@ jobs: set -e if node scripts/verify_npm_package.mjs \ --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ - --tarball "$NPM_PACKAGE" --attempts 6 --delay-ms 3000; then + --tarball "$NPM_PACKAGE" --attempts 36 --delay-ms 5000; then if [ "$PUBLISH_STATUS" -ne 0 ]; then echo "npm publish returned ${PUBLISH_STATUS}, but the tested tarball is present" fi From ba188e4685b8567d60032afa844fea27499679c2 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 8 Oct 2026 13:15:12 +0800 Subject: [PATCH 3/3] test(ci): check bounded npm processing wait --- tests/test_release_packaging.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index d161504..aa7beba 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -128,7 +128,12 @@ def test_npm_publish_checks_integrity_before_and_after_upload(self) -> None: self.assertIn("3) ;;", publish) self.assertIn('*) exit "$LOOKUP_STATUS"', publish) self.assertIn("PUBLISH_STATUS=$?", publish) - self.assertIn("--attempts 6 --delay-ms 3000 --timeout-ms 5000", publish) + post_publish = publish.split("PUBLISH_STATUS=$?", 1)[1] + retry = re.search(r"--attempts (\d+) --delay-ms (\d+) --timeout-ms (\d+)", post_publish) + self.assertIsNotNone(retry) + attempts, delay_ms, timeout_ms = map(int, retry.groups()) + self.assertGreaterEqual((attempts - 1) * delay_ms, 120_000) + self.assertLessEqual(attempts * timeout_ms + (attempts - 1) * delay_ms, 600_000) def test_manual_preflight_builds_all_assets_without_publication(self) -> None: workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")