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,
|
# Voyage project dirs (briefs, research, plans) — LOCAL-ONLY on this public remote,
|
||||||
# same rule as STATE.md. They carry operator paths and machine details.
|
# same rule as STATE.md. They carry operator paths and machine details.
|
||||||
.claude/projects/
|
.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