test(m1): assert plugin manifest and pin a build stamp
This commit is contained in:
parent
89bcd1b038
commit
0e3295f52b
4 changed files with 421 additions and 0 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -17,3 +17,10 @@ __pycache__/
|
|||
# Voyage project dirs (briefs, research, plans) — LOCAL-ONLY on this public remote,
|
||||
# same rule as STATE.md. They carry operator paths and machine details.
|
||||
.claude/projects/
|
||||
|
||||
# Build artefacts. BUILD_STAMP is the short hash of the commit a build came
|
||||
# from, regenerated by scripts/package_plugin.sh before every archive; a
|
||||
# committed stamp would be false the instant the next commit moved HEAD.
|
||||
# jobbsok.plugin is the archive that command writes into the repository root.
|
||||
BUILD_STAMP
|
||||
jobbsok.plugin
|
||||
|
|
|
|||
52
scripts/build_stamp.sh
Executable file
52
scripts/build_stamp.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/bin/bash
|
||||
# Write the short hash of the commit this build came from to BUILD_STAMP.
|
||||
#
|
||||
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
|
||||
#
|
||||
# Why a stamp and not the manifest version: Cowork caches an uploaded plugin,
|
||||
# and plugin.json's version does not change between milestones, so a stale
|
||||
# build would echo the right number and every answer after it would be about
|
||||
# some other build (risk H11). The short hash moves on every commit, which is
|
||||
# exactly the property the check needs.
|
||||
#
|
||||
# The stamp is a build artefact and is gitignored. It is regenerated by
|
||||
# scripts/package_plugin.sh before every archive; a committed stamp would be
|
||||
# false the instant the next commit moved HEAD.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/build_stamp.sh # write <plugin root>/BUILD_STAMP
|
||||
# bash scripts/build_stamp.sh --ut <sti> # write somewhere else
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
OUT="${REPO_ROOT}/BUILD_STAMP"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ut)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "build_stamp: --ut needs a path" >&2
|
||||
exit 2
|
||||
fi
|
||||
OUT="$2"
|
||||
shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "build_stamp: unknown argument: $1" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "build_stamp: $REPO_ROOT is not a git working tree." >&2
|
||||
echo "build_stamp: the stamp is the commit this build came from; there is nothing to stamp." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAMP=$(git -C "$REPO_ROOT" rev-parse --short HEAD)
|
||||
printf '%s\n' "$STAMP" > "$OUT"
|
||||
echo "build_stamp: $STAMP -> $OUT"
|
||||
98
scripts/package_plugin.sh
Executable file
98
scripts/package_plugin.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/bin/bash
|
||||
# Build jobbsok.plugin from an explicit include list.
|
||||
#
|
||||
# bash 3.2-clean and ASCII-only on purpose: the system bash on this Mac is 3.2.
|
||||
#
|
||||
# The include list is the whole point, and it is not a convenience. zip 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.sh once against it, which builds the environment under
|
||||
# $CLAUDE_PLUGIN_DATA. README.md says so.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/package_plugin.sh # -> <repo root>/jobbsok.plugin
|
||||
# bash scripts/package_plugin.sh --ut <sti> # -> somewhere else
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
OUT="${REPO_ROOT}/jobbsok.plugin"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ut)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "package_plugin: --ut needs a path" >&2
|
||||
exit 2
|
||||
fi
|
||||
OUT="$2"
|
||||
shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,23p' "$0"
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "package_plugin: unknown argument: $1" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$OUT" in
|
||||
/*) ;;
|
||||
*) OUT="$(pwd)/$OUT" ;;
|
||||
esac
|
||||
|
||||
# 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.
|
||||
bash "${SCRIPT_DIR}/build_stamp.sh"
|
||||
|
||||
# The include list. Directories are archived whole, minus the exclusions below.
|
||||
# Entries that do not exist yet are reported and skipped -- hooks/, templates/
|
||||
# and SECURITY.md land in later steps -- but the two the archive is worthless
|
||||
# without are required outright.
|
||||
INCLUDE=".claude-plugin skills scripts hooks templates .mcp.json README.md SECURITY.md LICENSE BUILD_STAMP"
|
||||
REQUIRED=".claude-plugin/plugin.json BUILD_STAMP"
|
||||
|
||||
for path in $REQUIRED; do
|
||||
if [ ! -e "${REPO_ROOT}/${path}" ]; then
|
||||
echo "package_plugin: required entry missing: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
PRESENT=""
|
||||
SKIPPED=""
|
||||
for path in $INCLUDE; do
|
||||
if [ -e "${REPO_ROOT}/${path}" ]; then
|
||||
PRESENT="$PRESENT $path"
|
||||
else
|
||||
SKIPPED="$SKIPPED $path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$SKIPPED" ]; then
|
||||
echo "package_plugin: not present yet, skipped:$SKIPPED"
|
||||
fi
|
||||
|
||||
rm -f "$OUT"
|
||||
|
||||
# -X drops the extra file attributes, so the archive is reproducible enough
|
||||
# that two builds of the same commit differ only in timestamps. The exclusions
|
||||
# are 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.
|
||||
cd "$REPO_ROOT"
|
||||
# shellcheck disable=SC2086 -- PRESENT is a deliberate word-split list
|
||||
zip -q -r -X "$OUT" $PRESENT \
|
||||
-x '*/__pycache__/*' '*.pyc' '*/.pytest_cache/*' '*/.venv/*' '*.local.md' \
|
||||
'*/.DS_Store'
|
||||
|
||||
echo "package_plugin: $(basename "$OUT") built from $(cat "${REPO_ROOT}/BUILD_STAMP")"
|
||||
echo "package_plugin: $OUT"
|
||||
264
tests/test_plugin_manifest.py
Normal file
264
tests/test_plugin_manifest.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""The plugin manifest, the build stamp and what the archive may contain
|
||||
(plan Step 13).
|
||||
|
||||
Two failures this file exists to prevent, and they are different in kind.
|
||||
|
||||
**The false green.** Cowork caches an uploaded plugin, and a cached build
|
||||
answers every question correctly except the one being asked. So the plugin
|
||||
carries a `BUILD_STAMP` -- the short hash of the commit it was built from --
|
||||
and the `Verifiser i Cowork` steps compare it against `git rev-parse --short
|
||||
HEAD`. `plugin.json`'s version cannot do that job: it does not change between
|
||||
milestones, so a stale build would echo the right number (risk H11).
|
||||
|
||||
**The over-broad archive.** The packaging command is printed in a public
|
||||
README and run before every Cowork upload. An archiver that walked the
|
||||
repository root would ship `.git`, the virtualenv, the local-only `STATE.md`
|
||||
and everything under `.claude/`, which holds the operator's decisions and
|
||||
absolute home paths. `zip` does not consult `.gitignore`, so the include list
|
||||
is the only thing standing between those files and an uploaded archive -- and
|
||||
the test reads the built archive's own table of contents rather than trusting
|
||||
the script's include list to be what it says it is.
|
||||
|
||||
The stamp being gitignored is asserted twice, deliberately: once against the
|
||||
text of `.gitignore` and once against `git check-ignore`, which is the only
|
||||
one of the two that knows about precedence, negations and later rules. An
|
||||
untested claim of "gitignored" is exactly how a stamp ends up tracked and the
|
||||
suite ends up permanently red from this step onward.
|
||||
|
||||
One divergence from the plan, stated rather than smuggled. The plan asks that
|
||||
`plugin.json`'s version equal "the top heading in CHANGELOG.md". The top
|
||||
heading today is `## [Unreleased]`, because nothing has been released --
|
||||
README says so on its first screen. Writing a `## [0.1.0]` heading to satisfy
|
||||
a test would assert a release that has not happened. So the check is the one
|
||||
the rule is actually for: **if** the changelog carries a version heading, the
|
||||
topmost one must equal `plugin.json`'s version. The day a release lands, the
|
||||
check tightens by itself.
|
||||
|
||||
Style note: this file follows tests/test_skills_contract.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
|
||||
CHANGELOG = os.path.join(REPO, "CHANGELOG.md")
|
||||
GITIGNORE = os.path.join(REPO, ".gitignore")
|
||||
SKILLS = os.path.join(REPO, "skills")
|
||||
BUILD_STAMP = os.path.join(REPO, "scripts", "build_stamp.sh")
|
||||
PACKAGE = os.path.join(REPO, "scripts", "package_plugin.sh")
|
||||
|
||||
#: Build-brief section 4. The count assertion -- that all fourteen exist --
|
||||
#: belongs to M6, when the last of them lands; what is asserted here is that
|
||||
#: no directory outside the list ever appears.
|
||||
SKILL_NAVN = (
|
||||
"kandidatprofil",
|
||||
"annonse-uttrekk",
|
||||
"kandidatvurdering",
|
||||
"beslutning",
|
||||
"sak",
|
||||
"korrespondanse",
|
||||
"soknad",
|
||||
"intervju",
|
||||
"referanser",
|
||||
"forhandling",
|
||||
"dagens",
|
||||
"utfall",
|
||||
"laering",
|
||||
"datahygiene",
|
||||
)
|
||||
|
||||
#: A path component that must never appear in the uploaded archive. Each of
|
||||
#: these carries something that is local-only, operator-private, or both.
|
||||
FORBUDT_I_ARKIV = (".git", ".venv", "STATE.md", ".claude/", "__pycache__", ".pytest_cache")
|
||||
|
||||
VERSJONSOVERSKRIFT = re.compile(r"^##\s*\[?(\d+\.\d+\.\d+)\]?", re.MULTILINE)
|
||||
|
||||
|
||||
def git(*args):
|
||||
ut = subprocess.run(
|
||||
["git", "-C", REPO] + list(args), capture_output=True, text=True
|
||||
)
|
||||
assert ut.returncode == 0, "git %s failed: %s" % (" ".join(args), ut.stderr)
|
||||
return ut.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def manifest():
|
||||
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def arkiv(tmp_path):
|
||||
"""Build the plugin archive into a temporary file and return its path.
|
||||
|
||||
A deliberately stale stamp is planted first. Without it the archive would
|
||||
carry a correct hash whether or not the packaging script regenerated one,
|
||||
and a mutation that dropped the regeneration survived every assertion in
|
||||
this file -- which is the H11 false green wearing the test's own colours.
|
||||
"""
|
||||
stempel = os.path.join(REPO, "BUILD_STAMP")
|
||||
fantes = os.path.exists(stempel)
|
||||
forrige = None
|
||||
if fantes:
|
||||
with open(stempel, "r", encoding="utf-8") as handle:
|
||||
forrige = handle.read()
|
||||
with open(stempel, "w", encoding="utf-8") as handle:
|
||||
handle.write("foreldet\n")
|
||||
|
||||
try:
|
||||
ut = tmp_path / "jobbsok.plugin"
|
||||
kjort = subprocess.run(
|
||||
["bash", PACKAGE, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
||||
)
|
||||
assert kjort.returncode == 0, "package_plugin.sh failed:\n%s" % kjort.stderr
|
||||
assert ut.exists(), "package_plugin.sh reported success but wrote no archive"
|
||||
yield str(ut)
|
||||
finally:
|
||||
if forrige is None:
|
||||
os.remove(stempel)
|
||||
else:
|
||||
with open(stempel, "w", encoding="utf-8") as handle:
|
||||
handle.write(forrige)
|
||||
|
||||
|
||||
def test_the_manifest_carries_the_five_fields_a_plugin_needs(manifest):
|
||||
for felt in ("name", "version", "description", "author", "license"):
|
||||
assert felt in manifest, "plugin.json is missing %r" % felt
|
||||
assert manifest[felt], "plugin.json has an empty %r" % felt
|
||||
assert manifest["name"] == "jobbsok"
|
||||
assert re.match(r"^\d+\.\d+\.\d+$", manifest["version"]), (
|
||||
"version %r is not semantic" % manifest["version"]
|
||||
)
|
||||
assert isinstance(manifest["author"], dict) and manifest["author"].get("name")
|
||||
|
||||
|
||||
def test_the_version_does_not_contradict_the_changelog(manifest):
|
||||
with open(CHANGELOG, "r", encoding="utf-8") as handle:
|
||||
tekst = handle.read()
|
||||
versjoner = VERSJONSOVERSKRIFT.findall(tekst)
|
||||
if not versjoner:
|
||||
# Pre-release: the only heading is [Unreleased], and that is honest.
|
||||
assert "[Unreleased]" in tekst, (
|
||||
"the changelog carries neither a version heading nor an "
|
||||
"Unreleased section; one of the two has to be true"
|
||||
)
|
||||
return
|
||||
assert versjoner[0] == manifest["version"], (
|
||||
"plugin.json says %r but the newest changelog heading says %r"
|
||||
% (manifest["version"], versjoner[0])
|
||||
)
|
||||
|
||||
|
||||
def test_this_plugin_is_skills_only_and_never_grows_a_commands_directory():
|
||||
assert not os.path.exists(os.path.join(REPO, "commands")), (
|
||||
"this plugin is skills-only (build-brief section 4); a commands/ "
|
||||
"directory would be a second, undocumented surface"
|
||||
)
|
||||
sporet = [p for p in git("ls-files").split("\n") if p.startswith("commands/")]
|
||||
assert sporet == [], "commands/ is tracked: %r" % sporet
|
||||
|
||||
|
||||
def test_every_skill_directory_is_one_the_brief_names():
|
||||
if not os.path.isdir(SKILLS):
|
||||
pytest.fail("skills/ does not exist")
|
||||
funnet = sorted(
|
||||
navn for navn in os.listdir(SKILLS)
|
||||
if os.path.isdir(os.path.join(SKILLS, navn))
|
||||
)
|
||||
ukjente = [navn for navn in funnet if navn not in SKILL_NAVN]
|
||||
assert ukjente == [], (
|
||||
"skills/ holds %r, which build-brief section 4 does not name. The "
|
||||
"fourteen are %r" % (ukjente, list(SKILL_NAVN))
|
||||
)
|
||||
assert funnet, "skills/ is empty; M1 ships two of the fourteen"
|
||||
|
||||
|
||||
def test_the_stamp_script_writes_the_hash_of_the_commit_it_ran_on(tmp_path):
|
||||
ut = tmp_path / "BUILD_STAMP"
|
||||
kjort = subprocess.run(
|
||||
["bash", BUILD_STAMP, "--ut", str(ut)], cwd=REPO, capture_output=True, text=True
|
||||
)
|
||||
assert kjort.returncode == 0, kjort.stderr
|
||||
with open(ut, "r", encoding="utf-8") as handle:
|
||||
stempel = handle.read().strip()
|
||||
assert stempel == git("rev-parse", "--short", "HEAD"), (
|
||||
"the stamp says %r but HEAD is %r" % (stempel, git("rev-parse", "--short", "HEAD"))
|
||||
)
|
||||
# Trailing newline and nothing else: the skills read this file and echo it.
|
||||
with open(ut, "r", encoding="utf-8") as handle:
|
||||
assert handle.read() == stempel + "\n"
|
||||
|
||||
|
||||
def test_the_build_artefacts_are_ignored_and_git_agrees():
|
||||
with open(GITIGNORE, "r", encoding="utf-8") as handle:
|
||||
linjer = [line.strip() for line in handle]
|
||||
assert "BUILD_STAMP" in linjer, (
|
||||
".gitignore has no BUILD_STAMP line. The stamp is regenerated on every "
|
||||
"package run, so a tracked stamp is red from the next commit onward."
|
||||
)
|
||||
assert "jobbsok.plugin" in linjer, (
|
||||
".gitignore has no jobbsok.plugin line; the README tells the operator "
|
||||
"to build that archive in the repository root"
|
||||
)
|
||||
|
||||
# The text is one thing; what git actually does is the thing. check-ignore
|
||||
# knows about precedence and negation, and a later rule could undo the line
|
||||
# above without the assertion over the text noticing.
|
||||
for navn in ("BUILD_STAMP", "jobbsok.plugin"):
|
||||
sti = os.path.join(REPO, navn)
|
||||
fantes = os.path.exists(sti)
|
||||
if not fantes:
|
||||
with open(sti, "w", encoding="utf-8") as handle:
|
||||
handle.write("probe\n")
|
||||
try:
|
||||
kjort = subprocess.run(
|
||||
["git", "-C", REPO, "check-ignore", "-q", navn],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert kjort.returncode == 0, (
|
||||
"git does not ignore %s despite the .gitignore line" % navn
|
||||
)
|
||||
finally:
|
||||
if not fantes:
|
||||
os.remove(sti)
|
||||
|
||||
|
||||
def test_the_archive_ships_no_local_only_or_operator_private_path(arkiv):
|
||||
with zipfile.ZipFile(arkiv) as pakke:
|
||||
oppforinger = pakke.namelist()
|
||||
|
||||
assert oppforinger, "the archive is empty"
|
||||
for oppforing in oppforinger:
|
||||
for forbudt in FORBUDT_I_ARKIV:
|
||||
assert forbudt not in oppforing, (
|
||||
"the archive ships %r, which contains %r. The include list is "
|
||||
"the only thing keeping it out -- zip does not read .gitignore."
|
||||
% (oppforing, forbudt)
|
||||
)
|
||||
|
||||
# And the things it must carry, so an over-eager exclusion is caught too.
|
||||
for pakket in (".claude-plugin/plugin.json", ".mcp.json", "BUILD_STAMP", "LICENSE"):
|
||||
assert pakket in oppforinger, (
|
||||
"the archive is missing %r; it holds %r" % (pakket, sorted(oppforinger))
|
||||
)
|
||||
assert any(o.startswith("skills/") for o in oppforinger)
|
||||
assert any(o.startswith("scripts/") for o in oppforinger)
|
||||
|
||||
# And the stamp it ships is the one this commit deserves. The fixture
|
||||
# planted a stale one on purpose, so a packaging script that stopped
|
||||
# regenerating would archive "foreldet" and be caught here rather than in
|
||||
# Cowork, six uploads later.
|
||||
with zipfile.ZipFile(arkiv) as pakke:
|
||||
pakket_stempel = pakke.read("BUILD_STAMP").decode("utf-8").strip()
|
||||
assert pakket_stempel == git("rev-parse", "--short", "HEAD"), (
|
||||
"the archive ships stamp %r while HEAD is %r; the packaging script did "
|
||||
"not regenerate it" % (pakket_stempel, git("rev-parse", "--short", "HEAD"))
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue