jobbsok/scripts/package_plugin.py
Kjell Tore Guttormsen 58db24ba24 fix(package): include pyproject.toml so an installed copy can bootstrap
scripts/bootstrap.py resolves --group against <repo-root>/pyproject.toml,
but package_plugin.py's INCLUDE tuple never shipped that file. An archive
built and unpacked elsewhere could never bootstrap (F1). Verify: built an
archive with package_plugin.py, unpacked it to a tmp dir, ran
scripts/bootstrap.py against the unpacked copy -- "bootstrap: guard 1.3.0"
/ "bootstrap: ok", exit 0. Also fixed two stale pyproject.toml comments
still naming the pre-port scripts/bootstrap.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:43:25 +02:00

161 lines
5.7 KiB
Python

"""Build jobbsok.plugin from an explicit include list.
Python rather than bash, and standard library only: `zip` is not on stock
Windows any more than `bash` is, and `zipfile` does the same job everywhere.
The include list is the whole point, and it is not a convenience. An archiver
does not consult .gitignore, so archiving the repository root would ship .git,
the virtualenv from Step 2, the local-only STATE.md and everything under
.claude/ -- which holds the operator's decisions and absolute home paths. This
archive is uploaded to Cowork once per milestone and its command is printed in
a public README. An include list is the only form that is safe to publish.
The virtualenv is excluded for a second reason on top of that one: a venv's
absolute paths do not survive relocation, so an archived one would be broken
wherever it landed. The supported route in an installed copy is to run
scripts/bootstrap.py once against it, which builds the environment under
$CLAUDE_PLUGIN_DATA. README.md says so.
Entry names are written with forward slashes and no Unix permission bits,
which is what the shell version's `-X` was after: two builds of the same commit
differ only in timestamps, and nothing in the archive needs an execute bit now
that every entry point is started by naming an interpreter.
Usage:
python3 scripts/package_plugin.py # -> <repo root>/jobbsok.plugin
python3 scripts/package_plugin.py --ut <sti> # -> somewhere else
On Windows the interpreter is normally called `python`, not `python3`.
"""
import fnmatch
import os
import subprocess
import sys
import zipfile
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
#: Directories are archived whole, minus the exclusions below. Entries that do
#: not exist yet are reported and skipped -- hooks/ and templates/ land in
#: later steps -- but the two the archive is worthless without are required.
INCLUDE = (
".claude-plugin",
"skills",
"scripts",
"hooks",
"templates",
".mcp.json",
"README.md",
"SECURITY.md",
"LICENSE",
"BUILD_STAMP",
"pyproject.toml",
)
REQUIRED = (".claude-plugin/plugin.json", "BUILD_STAMP")
#: Belt to the include list's braces: nothing under scripts/ or skills/ that is
#: a build artefact of a local test run may ride along. The patterns are the
#: ones the shell version passed to `zip -x`, matched against the archive entry
#: name, where `*` crosses directory separators exactly as it did there.
UTELAT = (
"*/__pycache__/*",
"*.pyc",
"*/.pytest_cache/*",
"*/.venv/*",
"*.local.md",
"*/.DS_Store",
)
def parse_args(argv):
"""Return the archive path, or exit the way the shell version exited."""
ut = os.path.join(REPO_ROOT, "jobbsok.plugin")
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--ut":
if i + 1 >= len(argv):
sys.stderr.write("package_plugin: --ut needs a path\n")
raise SystemExit(2)
ut = argv[i + 1]
i += 2
elif arg in ("-h", "--help"):
sys.stdout.write(__doc__)
raise SystemExit(0)
else:
sys.stderr.write("package_plugin: unknown argument: %s\n" % arg)
raise SystemExit(2)
return os.path.abspath(ut)
def skal_utelates(navn):
"""True when ``navn`` -- an archive entry name -- matches an exclusion."""
for monster in UTELAT:
if fnmatch.fnmatch(navn, monster):
return True
return False
def oppforinger(rot, include):
"""Return [(absolute path, archive entry name)] for ``include`` under ``rot``.
Sorted, so two builds of the same tree lay the archive out identically.
"""
funnet = []
for sti in include:
full = os.path.join(rot, sti)
if os.path.isfile(full):
funnet.append((full, sti.replace(os.sep, "/")))
continue
for dirpath, dirnames, filenames in os.walk(full):
dirnames.sort()
for filnavn in sorted(filenames):
filsti = os.path.join(dirpath, filnavn)
navn = os.path.relpath(filsti, rot).replace(os.sep, "/")
funnet.append((filsti, navn))
return [(f, n) for f, n in funnet if not skal_utelates(n)]
def main(argv):
ut = parse_args(argv)
# Regenerate first: an archive carrying a stale stamp is the exact failure
# the stamp exists to catch, and building one would be worse than not
# stamping. Started with this interpreter, so the packaging run and the
# stamp it ships cannot end up on two different Pythons.
stempling = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "build_stamp.py")]
)
if stempling.returncode != 0:
return stempling.returncode
for sti in REQUIRED:
if not os.path.exists(os.path.join(REPO_ROOT, sti.replace("/", os.sep))):
sys.stderr.write("package_plugin: required entry missing: %s\n" % sti)
return 1
present = [p for p in INCLUDE if os.path.exists(os.path.join(REPO_ROOT, p))]
skipped = [p for p in INCLUDE if not os.path.exists(os.path.join(REPO_ROOT, p))]
if skipped:
sys.stdout.write("package_plugin: not present yet, skipped: %s\n" % " ".join(skipped))
if os.path.exists(ut):
os.remove(ut)
with zipfile.ZipFile(ut, "w", zipfile.ZIP_DEFLATED) as pakke:
for filsti, navn in oppforinger(REPO_ROOT, present):
pakke.write(filsti, navn)
with open(os.path.join(REPO_ROOT, "BUILD_STAMP"), "r", encoding="utf-8") as handle:
stempel = handle.read().strip()
sys.stdout.write(
"package_plugin: %s built from %s\n" % (os.path.basename(ut), stempel)
)
sys.stdout.write("package_plugin: %s\n" % ut)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))