diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d42e69..7e15157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixed + +- Pinned standalone ZIP entry times, process time zone, and line-ending settings + so the same Git/platform toolchain produces committed bytes and a stable + SHA-256 across invocation times and host configurations, with toolchain limits + documented. + ## [0.4.2] - 2026-07-17 ### Added diff --git a/docs/install.md b/docs/install.md index 839a584..1079675 100644 --- a/docs/install.md +++ b/docs/install.md @@ -247,13 +247,50 @@ Build from a committed tree so untracked files cannot leak into the archive: ```bash ( set -euo pipefail -git archive --format=zip --prefix=watchlist-md/ \ +python_check='import sys; raise SystemExit(sys.version_info < (3, 8))' +if python3 -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python3 +elif python -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3.8 or newer is required" >&2 + exit 1 +fi +archive_ref=$(git rev-parse HEAD) +archive_mtime=$(git show -s --format=%cI "${archive_ref}") +archive_check_tree=$(mktemp -d) +mkdir "${archive_check_tree}/evals" +trap 'rm -f "${archive_check_tree}/evals/check_skill_package.py" "${archive_check_tree}/evals/runtime_package_files.txt"; rmdir "${archive_check_tree}/evals" "${archive_check_tree}"' EXIT +git show "${archive_ref}:evals/check_skill_package.py" \ + >"${archive_check_tree}/evals/check_skill_package.py" +git show "${archive_ref}:evals/runtime_package_files.txt" \ + >"${archive_check_tree}/evals/runtime_package_files.txt" +TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive \ + --format=zip --prefix=watchlist-md/ --mtime="${archive_mtime}" \ --output=watchlist-md-skill.zip \ - HEAD:.agents/skills/watchlist-md -python3 evals/check_skill_package.py --archive watchlist-md-skill.zip + "${archive_ref}:.agents/skills/watchlist-md" +"${python_cmd}" "${archive_check_tree}/evals/check_skill_package.py" \ + --archive watchlist-md-skill.zip +rm -f "${archive_check_tree}/evals/check_skill_package.py" \ + "${archive_check_tree}/evals/runtime_package_files.txt" +rmdir "${archive_check_tree}/evals" "${archive_check_tree}" +trap - EXIT ) ``` +This recipe requires Git 2.40 or newer with `git archive --mtime` support. The +explicit commit time, UTC process time zone, and disabled checkout line-ending +conversion keep committed file bytes and repeated output stable with the same +Git/platform toolchain. A subtree expression resolves to a tree object, so +omitting `--mtime` would stamp entries with the current time; omitting `TZ=UTC` +would use the host time zone; omitting `core.autocrlf=false` can rewrite line +endings. Git ZIP metadata and compression can still differ across other Git +builds, so cross-toolchain byte identity is not promised. Run the fenced recipe +in Bash (Git Bash on Windows); a native PowerShell translation must set +`$env:TZ = 'UTC'` for the archive command and restore the previous value afterward. +The checker and manifest are also read from the pinned commit, so concurrent +working-tree or `HEAD` changes cannot silently change the validation contract. + The archive contains `watchlist-md/SKILL.md` and `watchlist-md/LICENSE.txt` under one top-level folder and remains Python-free. Repository `tools/`, `evals/`, maintainer docs, transcripts, screenshots, and raw logs must not be packaged. diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 1df1039..cd21d48 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -31,13 +31,25 @@ transcripts, screenshots, and raw logs. Run: ```bash -PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s evals -p 'test_*.py' -python3 evals/check_policy_markers.py -python3 evals/check_semantic_cases.py -python3 evals/check_skill_package.py -python3 evals/check_release_metadata.py -python3 evals/check_watchlist.py examples/WATCHLIST.example.md --strict-format --strict-safety --require-archive-section -python3 tools/validate_watchlist.py .agents/skills/watchlist-md/assets/WATCHLIST.template.md --strict-format --strict-safety --require-archive-section +( +set -euo pipefail +python_check='import sys; raise SystemExit(sys.version_info < (3, 8))' +if python3 -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python3 +elif python -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3.8 or newer is required" >&2 + exit 1 +fi +PYTHONDONTWRITEBYTECODE=1 "${python_cmd}" -m unittest discover -s evals -p 'test_*.py' +"${python_cmd}" evals/check_policy_markers.py +"${python_cmd}" evals/check_semantic_cases.py +"${python_cmd}" evals/check_skill_package.py +"${python_cmd}" evals/check_release_metadata.py +"${python_cmd}" evals/check_watchlist.py examples/WATCHLIST.example.md --strict-format --strict-safety --require-archive-section +"${python_cmd}" tools/validate_watchlist.py .agents/skills/watchlist-md/assets/WATCHLIST.template.md --strict-format --strict-safety --require-archive-section +) ``` Confirm no unintended runtime bundle change against the PR base and local tree: @@ -67,7 +79,16 @@ Run the release-ready metadata check: ```bash ( set -euo pipefail -python3 evals/check_release_metadata.py --release +python_check='import sys; raise SystemExit(sys.version_info < (3, 8))' +if python3 -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python3 +elif python -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3.8 or newer is required" >&2 + exit 1 +fi +"${python_cmd}" evals/check_release_metadata.py --release version=$(cat VERSION) git fetch origin --tags if git show-ref --verify --quiet "refs/tags/v${version}"; then @@ -113,34 +134,60 @@ Build from the exact merged commit, not from an untracked working directory: ```bash ( set -euo pipefail +python_check='import sys; raise SystemExit(sys.version_info < (3, 8))' +if python3 -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python3 +elif python -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3.8 or newer is required" >&2 + exit 1 +fi git fetch origin main --tags release_sha=$(git rev-parse origin/main) test "$(git rev-parse HEAD)" = "${release_sha}" test -z "$(git status --porcelain)" version=$(git show "${release_sha}:VERSION") +release_mtime=$(git show -s --format=%cI "${release_sha}") release_tree=$(mktemp -d) mkdir "${release_tree}/evals" -trap 'rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" "${release_tree}/evals/check_release_metadata.py"; rmdir "${release_tree}/evals" "${release_tree}"' EXIT +trap 'rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" "${release_tree}/evals/check_release_metadata.py" "${release_tree}/evals/check_skill_package.py" "${release_tree}/evals/runtime_package_files.txt"; rmdir "${release_tree}/evals" "${release_tree}"' EXIT git show "${release_sha}:VERSION" >"${release_tree}/VERSION" git show "${release_sha}:CHANGELOG.md" >"${release_tree}/CHANGELOG.md" git show "${release_sha}:evals/check_release_metadata.py" \ >"${release_tree}/evals/check_release_metadata.py" -python3 "${release_tree}/evals/check_release_metadata.py" "${release_tree}" --release -rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" \ - "${release_tree}/evals/check_release_metadata.py" -rmdir "${release_tree}/evals" "${release_tree}" -trap - EXIT +git show "${release_sha}:evals/check_skill_package.py" \ + >"${release_tree}/evals/check_skill_package.py" +git show "${release_sha}:evals/runtime_package_files.txt" \ + >"${release_tree}/evals/runtime_package_files.txt" +"${python_cmd}" "${release_tree}/evals/check_release_metadata.py" "${release_tree}" --release mkdir -p dist artifact="dist/watchlist-md-skill-v${version}.zip" -git archive --format=zip --prefix=watchlist-md/ --output="${artifact}" "${release_sha}:.agents/skills/watchlist-md" -python3 evals/check_skill_package.py --archive "${artifact}" +TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive \ + --format=zip --prefix=watchlist-md/ --mtime="${release_mtime}" \ + --output="${artifact}" "${release_sha}:.agents/skills/watchlist-md" +"${python_cmd}" "${release_tree}/evals/check_skill_package.py" --archive "${artifact}" sha256sum "${artifact}" +rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" \ + "${release_tree}/evals/check_release_metadata.py" \ + "${release_tree}/evals/check_skill_package.py" \ + "${release_tree}/evals/runtime_package_files.txt" +rmdir "${release_tree}/evals" "${release_tree}" +trap - EXIT ) ``` The archive must contain one top-level `watchlist-md/` directory and the exact -seven runtime files. On PowerShell, use `Get-FileHash -Algorithm SHA256` instead -of `sha256sum`. +seven runtime files. This recipe requires Git 2.40 or newer with `git archive +--mtime` support. Pinning the entry time to the source commit and Git's process +time zone to UTC, and checkout line-ending conversion off makes committed file +bytes and repeated builds stable with the same Git/platform toolchain. It does +not promise identical bytes across every Git build or compression implementation. +Record the release OS, `git --version`, and SHA-256 with the release evidence. +Run the fenced recipe in Bash (Git Bash on Windows); a native PowerShell +translation must set `$env:TZ = 'UTC'` for the archive command, restore the +previous value afterward, and use `Get-FileHash -Algorithm SHA256` instead of +`sha256sum`. ## Publish And Verify @@ -149,17 +196,39 @@ Only publish after required `main` CI checks succeed for `release_sha`: ```bash ( set -euo pipefail +python_check='import sys; raise SystemExit(sys.version_info < (3, 8))' +if python3 -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python3 +elif python -c "${python_check}" >/dev/null 2>&1; then + python_cmd=python +else + echo "Python 3.8 or newer is required" >&2 + exit 1 +fi git fetch origin main --tags release_sha=$(git rev-parse origin/main) version=$(git show "${release_sha}:VERSION") +release_mtime=$(git show -s --format=%cI "${release_sha}") artifact="dist/watchlist-md-skill-v${version}.zip" test "$(git rev-parse HEAD)" = "${release_sha}" test -z "$(git status --porcelain)" -python3 evals/check_release_metadata.py --release +release_tree=$(mktemp -d) +mkdir "${release_tree}/evals" +trap 'rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" "${release_tree}/evals/check_release_metadata.py" "${release_tree}/evals/check_skill_package.py" "${release_tree}/evals/runtime_package_files.txt"; rmdir "${release_tree}/evals" "${release_tree}"' EXIT +git show "${release_sha}:VERSION" >"${release_tree}/VERSION" +git show "${release_sha}:CHANGELOG.md" >"${release_tree}/CHANGELOG.md" +git show "${release_sha}:evals/check_release_metadata.py" \ + >"${release_tree}/evals/check_release_metadata.py" +git show "${release_sha}:evals/check_skill_package.py" \ + >"${release_tree}/evals/check_skill_package.py" +git show "${release_sha}:evals/runtime_package_files.txt" \ + >"${release_tree}/evals/runtime_package_files.txt" +"${python_cmd}" "${release_tree}/evals/check_release_metadata.py" "${release_tree}" --release mkdir -p dist -git archive --format=zip --prefix=watchlist-md/ \ +TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive \ + --format=zip --prefix=watchlist-md/ --mtime="${release_mtime}" \ --output="${artifact}" "${release_sha}:.agents/skills/watchlist-md" -python3 evals/check_skill_package.py --archive "${artifact}" +"${python_cmd}" "${release_tree}/evals/check_skill_package.py" --archive "${artifact}" sha256sum "${artifact}" run_id=$(gh run list --repo dd3ok/WATCHLIST.md --workflow CI --event push \ --commit "${release_sha}" --limit 1 --json databaseId \ @@ -178,9 +247,15 @@ git fetch origin --tags test "$(git rev-list -n 1 "v${version}")" = "${release_sha}" gh release view "v${version}" --repo dd3ok/WATCHLIST.md \ --json tagName,publishedAt,targetCommitish,assets,url +rm -f "${release_tree}/VERSION" "${release_tree}/CHANGELOG.md" \ + "${release_tree}/evals/check_release_metadata.py" \ + "${release_tree}/evals/check_skill_package.py" \ + "${release_tree}/evals/runtime_package_files.txt" +rmdir "${release_tree}/evals" "${release_tree}" +trap - EXIT ) ``` -Verify the uploaded asset name, SHA-256 digest, tag target, and release URL. If -any check fails, stop and report it; do not move or recreate a published tag -silently. +Verify the uploaded asset name, SHA-256 digest, tag target, release URL, release +OS, and Git version. If any check fails, stop and report it; do not move or +recreate a published tag silently. diff --git a/evals/check_policy_markers.py b/evals/check_policy_markers.py index 55f5a1d..3f3dcb6 100644 --- a/evals/check_policy_markers.py +++ b/evals/check_policy_markers.py @@ -54,8 +54,13 @@ "Before updating, inspect whether the installed copy has local changes", "backup_root=\"$HOME/.watchlist-md-skill-backups/claude\"", "mktemp -d", - "git archive --format=zip --prefix=watchlist-md/", - "python3 evals/check_skill_package.py --archive watchlist-md-skill.zip", + "--format=zip --prefix=watchlist-md/", + "archive_ref=$(git rev-parse HEAD)", + "TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive", + "Git 2.40 or newer", + '--mtime="${archive_mtime}"', + "check_skill_package.py", + "--archive watchlist-md-skill.zip", "watchlist-md/SKILL.md", "watchlist-md/LICENSE.txt", "not the repository root", @@ -86,13 +91,19 @@ "docs/maintainers/release.md": [ "Release Checklist", "The installable skill bundle is intentionally Python-free", - "python3 evals/check_skill_package.py", - "python3 evals/check_release_metadata.py", - "python3 evals/check_release_metadata.py --release", + '"${python_cmd}" evals/check_skill_package.py', + '"${python_cmd}" evals/check_release_metadata.py', + '"${python_cmd}" evals/check_release_metadata.py --release', + "Python 3.8 or newer is required", "gh release create \"v${version}\"", "gh run watch \"${run_id}\"", "set -euo pipefail", - "git archive --format=zip --prefix=watchlist-md/", + "--format=zip --prefix=watchlist-md/", + "TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive", + "Git 2.40 or newer", + "same Git/platform toolchain", + 'release_mtime=$(git show -s --format=%cI "${release_sha}")', + '--mtime="${release_mtime}"', "git diff --name-only origin/main...HEAD -- .agents/skills/watchlist-md", "git diff --name-only -- .agents/skills/watchlist-md", "Repository-only files must stay outside `.agents/skills/watchlist-md/`", diff --git a/evals/check_skill_package.py b/evals/check_skill_package.py index 468cfb5..4be5808 100644 --- a/evals/check_skill_package.py +++ b/evals/check_skill_package.py @@ -133,7 +133,7 @@ def main(argv: list[str]) -> int: "Skill package manifest check failed:\n" + "\n".join(f"- {error}" for error in manifest_errors) ) - if not SKILL_DIR.is_dir(): + if args.archive is None and not SKILL_DIR.is_dir(): return fail(f"Missing skill directory: {SKILL_DIR}") if args.archive is not None: diff --git a/evals/test_check_watchlist.py b/evals/test_check_watchlist.py index c6471f6..add7b3d 100644 --- a/evals/test_check_watchlist.py +++ b/evals/test_check_watchlist.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import contextlib import io +import os import subprocess import sys import tempfile @@ -1114,6 +1115,17 @@ def test_install_and_release_docs_openai_zip_packaging_uses_one_top_level_skill_ self.assertIn("mktemp -d", install) self.assertNotIn("rm -rf", install) self.assertIn('mkdir -p "$HOME/.claude/skills"', install) + self.assertIn("archive_ref=$(git rev-parse HEAD)", install) + self.assertIn("Python 3.8 or newer is required", install) + self.assertIn("python_cmd=python3", install) + self.assertIn("python_cmd=python", install) + self.assertIn('archive_mtime=$(git show -s --format=%cI "${archive_ref}")', install) + self.assertIn('--mtime="${archive_mtime}"', install) + self.assertIn( + "TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive", install + ) + self.assertIn("Git 2.40 or newer", install) + self.assertIn("cross-toolchain byte identity is not promised", install) self.assertIn("Codex detects newly installed skills automatically", install) self.assertIn("$watchlist-md Add this to WATCHLIST.md", install) self.assertIn("## Vendor Paths And Guides", install) @@ -1137,6 +1149,19 @@ def test_install_and_release_docs_openai_zip_packaging_uses_one_top_level_skill_ self.assertIn("git diff --name-only -- .agents/skills/watchlist-md", release) self.assertIn('gh run watch "${run_id}"', release) self.assertGreaterEqual(release.count("set -euo pipefail"), 3) + self.assertEqual(release.count("Python 3.8 or newer is required"), 4) + self.assertEqual( + release.count('release_mtime=$(git show -s --format=%cI "${release_sha}")'), + 2, + ) + self.assertEqual(release.count('--mtime="${release_mtime}"'), 2) + self.assertEqual( + release.count("TZ=UTC git -c core.autocrlf=false -c core.eol=lf archive"), + 2, + ) + self.assertIn("Git 2.40 or newer", release) + self.assertIn("same Git/platform toolchain", release) + self.assertIn("not promise identical bytes across every Git build", release) self.assertNotIn( "git diff HEAD --name-only -- .agents/skills/watchlist-md", release, @@ -1145,10 +1170,11 @@ def test_install_and_release_docs_openai_zip_packaging_uses_one_top_level_skill_ for text in [install, release]: with self.subTest(): self.assertIn( - "git archive --format=zip --prefix=watchlist-md/", + "--format=zip --prefix=watchlist-md/", text, ) - self.assertIn("check_skill_package.py --archive", text) + self.assertIn("check_skill_package.py", text) + self.assertIn("--archive", text) self.assertIn("watchlist-md/SKILL.md", text) self.assertIn("tools/validate_watchlist.py", text) self.assertNotIn("watchlist-md/scripts/validate_watchlist.py", text) @@ -1172,6 +1198,80 @@ def test_skill_package_shape_checker_passes(self): self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertIn("Skill package check passed", result.stdout) + def test_archive_validation_does_not_require_a_source_skill_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = Path(tmpdir) / "package.zip" + PACKAGE_CHECK.build_package(archive_path) + original_skill_dir = PACKAGE_CHECK.SKILL_DIR + PACKAGE_CHECK.SKILL_DIR = Path(tmpdir) / "missing-skill" + try: + with contextlib.redirect_stdout(io.StringIO()): + result = PACKAGE_CHECK.main( + ["check_skill_package.py", "--archive", str(archive_path)] + ) + finally: + PACKAGE_CHECK.SKILL_DIR = original_skill_dir + + self.assertEqual(result, 0) + + def test_fixed_commit_mtime_and_utc_make_same_toolchain_archive_repeatable(self): + archive_help = subprocess.run( + ["git", "archive", "-h"], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ).stdout + if "--mtime" not in archive_help: + self.skipTest("git archive --mtime requires Git 2.40 or newer") + + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, text=True + ).strip() + commit_time = subprocess.check_output( + ["git", "show", "-s", "--format=%cI", commit], + cwd=REPO_ROOT, + text=True, + ).strip() + + with tempfile.TemporaryDirectory() as tmpdir: + archives = [Path(tmpdir) / "first.zip", Path(tmpdir) / "second.zip"] + for ambient_timezone, archive in zip(["UTC", "Asia/Seoul"], archives): + environment = os.environ.copy() + environment["TZ"] = ambient_timezone + archive_environment = environment.copy() + archive_environment["TZ"] = "UTC" + subprocess.run( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + "archive", + "--format=zip", + "--prefix=watchlist-md/", + f"--mtime={commit_time}", + f"--output={archive}", + f"{commit}:.agents/skills/watchlist-md", + ], + cwd=REPO_ROOT, + check=True, + env=archive_environment, + ) + + self.assertEqual(archives[0].read_bytes(), archives[1].read_bytes()) + self.assertEqual(PACKAGE_CHECK.validate_package(archives[0]), []) + with zipfile.ZipFile(archives[0]) as archive: + for packaged_path in PACKAGE_CHECK.REQUIRED_FILES: + repository_path = ".agents/skills/" + packaged_path + committed_bytes = subprocess.check_output( + ["git", "show", f"{commit}:{repository_path}"], + cwd=REPO_ROOT, + ) + self.assertEqual(archive.read(packaged_path), committed_bytes) + def test_documented_runtime_package_lists_match_manifest(self): expected_release = set(PACKAGE_CHECK.REQUIRED_FILES) expected_readme = {