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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 110 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -354,14 +368,15 @@ 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 \
--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
Expand Down Expand Up @@ -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 36 --delay-ms 5000; 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
184 changes: 184 additions & 0 deletions scripts/validate_release_recovery.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 14 additions & 3 deletions tests/test_release_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,18 @@ 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")
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])
Expand All @@ -142,7 +148,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:
Expand Down
Loading
Loading