Fix .gitignore swallowing the pipeline build package
The unanchored 'build/' pattern matched src/apworld_tester/pipeline/build/, so the package was never committed and a fresh clone failed at import with ModuleNotFoundError. Anchor build/ and dist/ to the repo root and add the missing module files. Also ignore .venv/.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,6 +21,9 @@ failed_apworlds.txt
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
# Build output.
|
# Build output.
|
||||||
build/
|
/build/
|
||||||
dist/
|
/dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# Virtual environment.
|
||||||
|
.venv/
|
||||||
|
|||||||
0
src/apworld_tester/pipeline/build/__init__.py
Normal file
0
src/apworld_tester/pipeline/build/__init__.py
Normal file
193
src/apworld_tester/pipeline/build/archipelago.py
Normal file
193
src/apworld_tester/pipeline/build/archipelago.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Updating the Archipelago checkout and building its images."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from apworld_tester.core.config.secrets import Secrets
|
||||||
|
from apworld_tester.core.state.keys import StateKeys
|
||||||
|
from apworld_tester.pipeline.build.docker import Docker
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipelagoBuild:
|
||||||
|
"""The Archipelago version this run tests against, and its image.
|
||||||
|
|
||||||
|
The checkout is only `docker build`'s context - a machine that
|
||||||
|
already has the image needs no clone at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: config.yaml values that mean "no pin - use the newest release".
|
||||||
|
UNPINNED = (None, "", "latest")
|
||||||
|
|
||||||
|
#: Appended to image_name for the unprivileged wrapper the tests
|
||||||
|
#: run in. Not configuration: the two images are one build, and a
|
||||||
|
#: name that did not derive from the base image's would only let
|
||||||
|
#: them drift apart.
|
||||||
|
TEST_SUFFIX = "-test"
|
||||||
|
|
||||||
|
#: GitHub's API, where releases are looked up.
|
||||||
|
API_ROOT = "https://api.github.com"
|
||||||
|
|
||||||
|
def __init__(self, config, paths, session=None):
|
||||||
|
self.config = config
|
||||||
|
self.paths = paths
|
||||||
|
self.session = session or self.anonymous_session(paths)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def anonymous_session(paths):
|
||||||
|
"""A session for the release lookup, with a token if there is
|
||||||
|
one. Only one request is made, so an anonymous one is fine -
|
||||||
|
the token just keeps it off the 60-an-hour limit.
|
||||||
|
"""
|
||||||
|
session = requests.Session()
|
||||||
|
token = Secrets(paths).github_token
|
||||||
|
if token:
|
||||||
|
session.headers["Authorization"] = f"token {token}"
|
||||||
|
return session
|
||||||
|
|
||||||
|
def setting(self, key, default=None):
|
||||||
|
return self.config.value("archipelago", key, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repository(self):
|
||||||
|
return self.setting("repository")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_url(self):
|
||||||
|
return self.setting("repo_url")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_name(self):
|
||||||
|
return self.setting("image_name", "archipelago")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def test_image_name(self):
|
||||||
|
"""The unprivileged image the generation tests run in."""
|
||||||
|
return f"{self.image_name}{self.TEST_SUFFIX}"
|
||||||
|
|
||||||
|
def image_tag(self, tag):
|
||||||
|
return f"{self.image_name}:{tag}"
|
||||||
|
|
||||||
|
def test_image_tag(self, tag):
|
||||||
|
return f"{self.test_image_name}:{tag}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pinned_version(self):
|
||||||
|
"""The tag to run against, or None to follow the newest release."""
|
||||||
|
value = self.config.section("archipelago").get("version")
|
||||||
|
return None if value in self.UNPINNED else str(value)
|
||||||
|
|
||||||
|
def latest_release(self):
|
||||||
|
"""The newest release's tag and publication date."""
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.API_ROOT}/repos/{self.repository}/releases/latest",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("tag_name"):
|
||||||
|
raise RuntimeError("GitHub returned no tag_name for the "
|
||||||
|
"latest release.")
|
||||||
|
return data["tag_name"], (data.get("published_at")
|
||||||
|
or data.get("created_at"))
|
||||||
|
|
||||||
|
def published_at(self, tag):
|
||||||
|
"""When one release was published, or None if unreadable."""
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.API_ROOT}/repos/{self.repository}/releases/tags/{tag}",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
return response.json().get("published_at")
|
||||||
|
|
||||||
|
def resolve_version(self):
|
||||||
|
"""The version to run against, and when it was published."""
|
||||||
|
pinned = self.pinned_version
|
||||||
|
if pinned:
|
||||||
|
return pinned, self.published_at(pinned)
|
||||||
|
return self.latest_release()
|
||||||
|
|
||||||
|
def ensure_checkout(self):
|
||||||
|
"""Clone Archipelago, or fetch into the clone already there."""
|
||||||
|
if os.path.isdir(os.path.join(self.paths.checkout, ".git")):
|
||||||
|
Docker.run(["git", "fetch", "--tags", "--quiet"],
|
||||||
|
cwd=self.paths.checkout)
|
||||||
|
else:
|
||||||
|
Docker.run(["git", "clone", "--quiet", self.repo_url,
|
||||||
|
self.paths.checkout])
|
||||||
|
|
||||||
|
def checkout_tag(self, tag):
|
||||||
|
"""Put the checkout exactly on one tag.
|
||||||
|
|
||||||
|
Force and clean, because the working tree is automation-owned:
|
||||||
|
it has to match the tag recorded in state, not whatever a
|
||||||
|
previous run left behind.
|
||||||
|
"""
|
||||||
|
Docker.run(
|
||||||
|
["git", "checkout", "--quiet", "--force", "--detach", tag],
|
||||||
|
cwd=self.paths.checkout,
|
||||||
|
)
|
||||||
|
Docker.run(["git", "clean", "--quiet", "-fd"],
|
||||||
|
cwd=self.paths.checkout)
|
||||||
|
|
||||||
|
def build_base_image(self, tag):
|
||||||
|
"""Build the image straight from the checkout."""
|
||||||
|
Docker.run(["docker", "build", "-t", self.image_tag(tag),
|
||||||
|
self.paths.checkout])
|
||||||
|
|
||||||
|
def build_test_image(self, tag):
|
||||||
|
"""Build the unprivileged wrapper the tests run in."""
|
||||||
|
Docker.run([
|
||||||
|
"docker", "build",
|
||||||
|
"-f", self.paths.test_dockerfile,
|
||||||
|
"--build-arg", f"BASE_IMAGE={self.image_tag(tag)}",
|
||||||
|
"--build-arg", f"RUNNER_UID={os.getuid()}",
|
||||||
|
"--build-arg", f"RUNNER_GID={os.getgid()}",
|
||||||
|
"-t", self.test_image_tag(tag),
|
||||||
|
os.path.dirname(self.paths.test_dockerfile),
|
||||||
|
])
|
||||||
|
|
||||||
|
def ensure_test_image(self, tag):
|
||||||
|
"""Build the test image if this machine does not have it."""
|
||||||
|
if not Docker.image_exists(self.test_image_tag(tag)):
|
||||||
|
self.build_test_image(tag)
|
||||||
|
|
||||||
|
def local_tags(self):
|
||||||
|
"""Versions already built as test images here, newest first."""
|
||||||
|
return Docker.local_tags(self.test_image_name)
|
||||||
|
|
||||||
|
def build_version(self, tag):
|
||||||
|
"""Put the checkout on this exact version and build its image.
|
||||||
|
|
||||||
|
Resolves nothing and records nothing: the caller has already
|
||||||
|
decided which version it needs, so a run whose image went
|
||||||
|
missing rebuilds that version rather than quietly upgrading to
|
||||||
|
whatever is newest.
|
||||||
|
"""
|
||||||
|
self.ensure_checkout()
|
||||||
|
self.checkout_tag(tag)
|
||||||
|
if not Docker.image_exists(self.image_tag(tag)):
|
||||||
|
self.build_base_image(tag)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def update_and_build(self, store):
|
||||||
|
"""Put the checkout on the right version and build its image."""
|
||||||
|
tag, published_at = self.resolve_version()
|
||||||
|
self.ensure_checkout()
|
||||||
|
self.checkout_tag(tag)
|
||||||
|
built = not Docker.image_exists(self.image_tag(tag))
|
||||||
|
if built:
|
||||||
|
self.build_base_image(tag)
|
||||||
|
previous = self.record_version(store, tag, published_at)
|
||||||
|
return tag, previous, tag != previous, built
|
||||||
|
|
||||||
|
def record_version(self, store, tag, published_at):
|
||||||
|
"""Write the resolved version into the state file."""
|
||||||
|
state = store.load()
|
||||||
|
previous = state.get(StateKeys.ARCHIPELAGO_TAG)
|
||||||
|
state[StateKeys.ARCHIPELAGO_TAG] = tag
|
||||||
|
if published_at is not None:
|
||||||
|
state[StateKeys.ARCHIPELAGO_TAG_PUBLISHED_AT] = published_at
|
||||||
|
store.save(state)
|
||||||
|
return previous
|
||||||
53
src/apworld_tester/pipeline/build/docker.py
Normal file
53
src/apworld_tester/pipeline/build/docker.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Running docker and git for the build."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class Docker:
|
||||||
|
"""The docker and git commands the build needs."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run(command, cwd=None):
|
||||||
|
"""Run a command, raising with its exit code if it fails."""
|
||||||
|
result = subprocess.run(command, cwd=cwd)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Command failed ({result.returncode}): "
|
||||||
|
f"{' '.join(command)}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def image_exists(cls, reference):
|
||||||
|
"""Whether docker already has this image locally."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", reference],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def version_key(tag):
|
||||||
|
"""Sort key ordering 0.6.10 above 0.6.9.
|
||||||
|
|
||||||
|
A string compare gets that pair backwards. A tag that is not
|
||||||
|
all-numeric sorts last rather than crashing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return (1, tuple(int(part) for part in tag.split(".")))
|
||||||
|
except ValueError:
|
||||||
|
return (0, ())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def local_tags(cls, name):
|
||||||
|
"""Versions of an image already built here, newest first."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "images", "--format", "{{.Tag}}", name],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
tags = {line.strip() for line in result.stdout.splitlines()
|
||||||
|
if line.strip()}
|
||||||
|
return sorted(tags - {"<none>"}, key=cls.version_key, reverse=True)
|
||||||
Reference in New Issue
Block a user