Skip to content
Closed
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
75 changes: 75 additions & 0 deletions .github/workflows/windows-perl-inventory-experiment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Windows Perl inventory and archive experiment

on:
push:
branches: [tapish-codex/windows-perl-inventory-20260909]

permissions:
contents: read

concurrency:
group: windows-perl-inventory-${{ github.ref }}
cancel-in-progress: false

jobs:
inventory:
name: Inventory unchanged pinned Windows Perl
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Restore normal Windows prerequisites without saving a cache
id: prerequisites
uses: ./.github/actions/setup-windows-native
with:
save-cache: "false"
- name: Inventory installed distribution
shell: pwsh
env:
PERL_CACHE_HIT: ${{ steps.prerequisites.outputs.cache-hit }}
run: >-
python scripts/inventory-windows-perl.py
--root 'C:\Strawberry'
--output "$env:RUNNER_TEMP/perl-inventory/installed.json.gz"
- name: Retain inventory metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-perl-installed-inventory
path: ${{ runner.temp }}/perl-inventory/installed.json.gz
if-no-files-found: error
compression-level: 0
retention-days: 7

# Cold native correctness is retained at 67fa357 in run 34332893760.
# This next stage measures archives only; it does not rerun native tests.
archive-roundtrip:
name: Measure full and reduced Perl archive round trips
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Restore unchanged native prerequisites without saving
id: prerequisites
uses: ./.github/actions/setup-windows-native
with:
save-cache: "false"
- name: Check archive and restoration controls
run: python scripts/measure-perl-archive.test.py
- name: Check reused directory-move controls
run: python scripts/measure-perl-cold.test.py
- name: Measure verified local archive round trips
env:
PERL_CACHE_HIT: ${{ steps.prerequisites.outputs.cache-hit }}
run: python scripts/measure-perl-archive.py
- name: Retain archive measurements and restoration evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-perl-archive
path: perl-archive-measurements/**
if-no-files-found: error
retention-days: 7
26 changes: 26 additions & 0 deletions docs/provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,29 @@ actual image linkage and refusal of a different executable. Apple's
defines frame image indexes and binary image UUID/architecture fields. Native
CI still disables debug information; retaining a binary does not restore absent
source line tables or identify which individual nextest test executed it.

## Windows Perl inventory experiment

The installed-distribution inventory script, tests and dedicated experiment
workflow are project-authored under this repository's Apache-2.0 license.
Artifacts contain relative file names, byte counts, SHA-256 digests and scoped
Perl runtime identity. The distribution is supplied by the existing pinned
Windows prerequisite action, retaining its upstream attribution resources.

The cold-correctness helper reuses the project-authored bounded log collector
and the existing disposable SQLCipher regression test. Its filesystem failure
controls use this test file's own bytes and explicit replacement bytes; they
are not evidence that a reduced Perl runtime builds native dependencies.
The dedicated Windows comparison temporarily moves three inventoried MinGW
build directories on disposable runners, restores their bytes, and retains
fresh native-build timings plus source and installation hashes. It does not
save a distribution or compiler cache, change production prerequisites, or
establish a cache-extraction speed improvement.

The archive measurement helper and its controls are project-authored. It reuses
the installed inventory, directory-move control and bounded log capture helpers.
Its disposable Windows workflow uses the existing GNU tar/zstd commands and
performs full byte/hash round trips before reporting timings. The prior cold
native comparison is retained in run 34332893760 at commit 67fa357; the archive
stage does not rerun native tests or save production caches. Archive payloads
stay on the disposable runner; uploaded evidence contains metadata and logs.
127 changes: 127 additions & 0 deletions scripts/inventory-windows-perl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Read the installed pinned Perl tree; never alter its files or cache."""
import argparse
from collections import defaultdict
import gzip
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import time

MAX_FILES = 50000
MAX_BYTES = 4 * 1024**3


def reject_link(path):
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
raise ValueError("Perl inventory refuses symlinks and Windows reparse points")
return info


def inventory(root):
reject_link(root)
if not root.is_dir():
raise ValueError("Perl inventory root is not a directory")
files = []
groups = defaultdict(lambda: {"files": 0, "bytes": 0})
total = 0

def walk_error(error):
raise error

for directory, directories, names in os.walk(root, onerror=walk_error, followlinks=False):
for name in directories:
reject_link(Path(directory) / name)
for name in names:
path = Path(directory) / name
before = reject_link(path)
if not stat.S_ISREG(before.st_mode):
raise ValueError("Perl inventory encountered a non-regular file")
if len(files) >= MAX_FILES or total + before.st_size > MAX_BYTES:
raise ValueError("Perl inventory exceeded its file/byte bound")
digest = hashlib.sha256()
read_bytes = 0
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
read_bytes += len(chunk)
after = reject_link(path)
if (before.st_size, before.st_mtime_ns, before.st_ino) != (
after.st_size, after.st_mtime_ns, after.st_ino
) or read_bytes != before.st_size:
raise ValueError("Perl inventory file changed during hashing")
relative = path.relative_to(root).as_posix()
parts = relative.split("/")
group = "/".join(parts[:2]) if len(parts) > 2 else parts[0]
files.append({"path": relative, "bytes": read_bytes, "sha256": digest.hexdigest()})
groups[group]["files"] += 1
groups[group]["bytes"] += read_bytes
total += read_bytes
if not files:
raise ValueError("Perl inventory root is empty")
return {"total_files": len(files), "total_bytes": total,
"groups": dict(sorted(groups.items())), "files": sorted(files, key=lambda row: row["path"])}


def perl_identity(root):
perl = root / "perl/bin/perl.exe"
for path in (root, root / "perl", root / "perl/bin", perl):
reject_link(path)
code = ('print JSON::PP::encode_json({version=>"$^V", arch=>$Config{archname}, '
'module=>$INC{"Locale/Maketext/Simple.pm"}})')
result = subprocess.run([str(perl), "-MConfig", "-MJSON::PP", "-MLocale::Maketext::Simple", "-e", code],
capture_output=True, text=True, timeout=30, check=True)
if len(result.stdout) > 4096 or result.stderr:
raise ValueError("Unexpected Perl identity output")
identity = json.loads(result.stdout)
if identity["version"] != "v5.42.2":
raise ValueError("Pinned Perl runtime version mismatch")
module = Path(identity.pop("module")).resolve()
identity["module_path"] = module.relative_to(root.resolve()).as_posix()
return identity


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if os.name != "nt":
raise ValueError("Installed-runtime inventory requires Windows")
if args.output.resolve().is_relative_to(args.root.resolve()):
raise ValueError("Inventory output must be outside the distribution")
commit = os.environ["GITHUB_SHA"]
run_id = os.environ["GITHUB_RUN_ID"]
if not re.fullmatch(r"[0-9a-f]{40}", commit) or not run_id.isdigit():
raise ValueError("Missing source/run identity")
started = time.monotonic()
identity = perl_identity(args.root)
result = inventory(args.root)
module = identity["module_path"]
if module not in {row["path"] for row in result["files"]}:
raise ValueError("Validated Perl module is absent from the inventory")
result.update({
"schema_version": 1, "source_sha": commit, "run_id": int(run_id),
"run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
"runner_image": os.environ["ImageVersion"], "runner_os": os.environ["ImageOS"],
"configured_distribution_version": "5.42.2.1", "perl_identity": identity,
"cache_key": "bridge-windows-strawberryperl-5.42.2.1-v1",
"cache_hit": os.environ["PERL_CACHE_HIT"],
"inventory_seconds": round(time.monotonic() - started, 6),
"scope": "Installed distribution inventory and prerequisite identity only; no reduced cache or native-build qualification.",
})
if result["cache_hit"] not in ("true", "false", ""):
raise ValueError("Unexpected cache restore output")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(gzip.compress((json.dumps(result, indent=2) + "\n").encode(), mtime=0))
print(json.dumps({key: result[key] for key in
("source_sha", "run_id", "total_files", "total_bytes", "cache_hit", "perl_identity", "inventory_seconds")}))


if __name__ == "__main__":
main()
42 changes: 42 additions & 0 deletions scripts/inventory-windows-perl.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import hashlib
import importlib.util
from pathlib import Path
import tempfile
import unittest

spec = importlib.util.spec_from_file_location("inventory_windows_perl", Path(__file__).with_name("inventory-windows-perl.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)


class InventoryTests(unittest.TestCase):
def test_reads_actual_source_bytes_and_relative_paths(self):
source = Path(__file__).read_bytes()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "perl/bin").mkdir(parents=True)
(root / "perl/bin/source.txt").write_bytes(source)
result = module.inventory(root)
self.assertEqual(result["total_files"], 1)
self.assertEqual(result["total_bytes"], len(source))
self.assertEqual(result["files"], [{"path": "perl/bin/source.txt", "bytes": len(source),
"sha256": hashlib.sha256(source).hexdigest()}])

def test_missing_or_empty_root_cannot_report_zero_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with self.assertRaises(FileNotFoundError):
module.inventory(root / "missing")
with self.assertRaises(ValueError):
module.inventory(root)

def test_symlink_outside_distribution_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "linked").symlink_to(Path(__file__).resolve())
with self.assertRaises(ValueError):
module.inventory(root)


if __name__ == "__main__":
unittest.main()
Loading
Loading