Compare commits

...
22 Commits
Author SHA1 Message Date
ArchiveBox Release Bot a67071af2b Bump release version to 0.9.65rc15 2026-09-26 22:34:06 +00:00
Nick Sweeting b39ce2a315 Keep dependency fingerprint test version agnostic 2026-09-26 15:33:45 -07:00
ArchiveBox Release Bot b25c6fa093 Bump release version to 0.9.65rc14 2026-09-26 22:18:37 +00:00
ArchiveBox Release Bot c965f790f9 Bump for abx-dl 1.13.53 2026-09-26 22:18:17 +00:00
ArchiveBox Release Bot d276887f00 Bump release version to 0.9.65rc13 2026-09-26 22:07:36 +00:00
Nick Sweeting 976156141a Fix idle daemon spin on legacy retry timestamps 2026-09-26 15:07:12 -07:00
ArchiveBox Release Bot 6443f33d03 Bump release version to 0.9.65rc12 2026-09-26 22:00:08 +00:00
Nick Sweeting ef2745122a Merge remote-tracking branch 'origin/dev' into dev 2026-09-26 14:59:32 -07:00
Nick Sweeting 5e6adafc0e Stop detached snapshot progress panels from polling 2026-09-26 14:59:12 -07:00
ArchiveBox Release Bot bf22d15de2 Bump release version to 0.9.65rc11 2026-09-26 21:53:15 +00:00
Nick Sweeting 2bb48cbef0 Tolerate disappearing SQLite journals during CLI startup 2026-09-26 14:52:46 -07:00
ArchiveBox Release Bot 28c210a829 Bump release version to 0.9.65rc10 2026-09-26 21:44:55 +00:00
ArchiveBox Release Bot 2bd63b91af Bump for abx-dl 1.13.52 2026-09-26 21:44:33 +00:00
ArchiveBox Release Bot 4198ca64f6 Bump release version to 0.9.65rc9 2026-09-26 21:34:47 +00:00
Nick Sweeting a44e2fa36d Exclude zram swap from memory headroom 2026-09-26 14:33:55 -07:00
ArchiveBox Release Bot 89c58d4bbb Bump release version to 0.9.65rc8 2026-09-26 21:26:38 +00:00
Nick Sweeting 3c02fa9b13 Gate stable releases on tested downloader image digest 2026-09-26 14:25:53 -07:00
ArchiveBox Release Bot 0e21ec21bc Bump release version to 0.9.65rc7 2026-09-26 21:19:37 +00:00
Nick Sweeting ce7b437ee8 Fail staging acceptance when kernel history cannot be read 2026-09-26 14:19:09 -07:00
ArchiveBox Release Bot ebf7ff428a Bump release version to 0.9.65rc6 2026-09-26 21:02:05 +00:00
Nick Sweeting 04af3ad85d Measure capture memory before admitting parallel snapshots 2026-09-26 14:01:47 -07:00
Nick Sweeting ba50085fa0 Require live dev acceptance on both test servers before stable publication 2026-09-26 14:01:13 -07:00
24 changed files with 1407 additions and 40 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Stable releases require live dev acceptance
The order is **dev CI → dev image publication → automatic Cabbage and DigestBox
deployment → real capture and browser acceptance → stable publication**. Never
substitute a published PyPI wheel, healthy container, or local build for this
acceptance. The release workflow blocks *before* a stable upload unless both
environments have successful evidence from `staging-acceptance.yml` matching all
tracked source and dependency pins. Only ArchiveBox's own version fields may
change between dev and stable.
The staging workflow observes the existing image watchers; it cannot deploy or
restart the applications. On both hosts, install `bin/staging-acceptance-host.sh`
as root-owned mode 0755 `/usr/local/sbin/archivebox-staging-acceptance`. Its hash
is checked against the candidate. A dedicated SSH key must use
`restrict,command="/usr/local/sbin/archivebox-staging-acceptance"` in authorized
keys. It accepts only `inspect <source-sha>` and `capture <source-sha>`; it cannot
run caller-provided commands or archive arbitrary URLs.
The `staging` GitHub environment is restricted to dev/main branches. It contains
`STAGING_SSH_KEY` and `STAGING_KNOWN_HOSTS`, with independently verified host keys.
Only trusted release events use the private ugNAS runner route. Never expose
these credentials to PR jobs or copy an operator's existing root key.
Acceptance creates one tagged background crawl per source through the normal
add/runner path, enabling every snapshot extractor, including TLSNotary. It
checks installed pins, completed outputs and their files, unexpected/missing
plugin results, host OOM kills, and real rendered snapshot pages. Logs, result
inventories and screenshots are retained as Actions artifacts. Failures remain
failures; do not publish a success deployment record to bypass them. No code
mounts are allowed. Both matrix jobs must pass before their records authorize a
stable upload. Queued capture progress is polled; failed tests are not retried.
+3
View File
@@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- ugnas-linux
+4
View File
@@ -552,6 +552,9 @@ jobs:
- name: Export digest
if: inputs.push_digests
env:
ABX_DL_IMAGE_REF: ${{ steps.abx_dl_image.outputs.image }}
ARTIFACT_NAME: ${{ matrix.artifact_name }}
shell: bash
run: |
set -Eeuo pipefail
@@ -559,6 +562,7 @@ jobs:
digest="${{ steps.docker_build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
printf '%s\n' '${{ inputs.source_ref || github.sha }}' > /tmp/digests/source-ref
printf '%s\n' "$ABX_DL_IMAGE_REF" > "/tmp/digests/abx-dl-image-$ARTIFACT_NAME"
- name: Upload digest
if: inputs.push_digests
+22
View File
@@ -144,10 +144,12 @@ jobs:
runs-on: ubuntu-24.04
env:
CI_RUN_ID: ${{ needs.candidate.outputs.artifact_run_id }}
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
environment: pypi
permissions:
actions: read
contents: write
deployments: read
id-token: write
outputs:
version: ${{ steps.version.outputs.version }}
@@ -171,6 +173,26 @@ jobs:
version: "0.11.3"
enable-cache: false
# This precedes every stable upload, including workflow_dispatch. A green
# build is not evidence that our automatically updated test servers ran it.
- name: Download tested Docker base-image metadata
if: env.RELEASE_BRANCH == 'main'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: ${{ runner.temp }}/tested-docker-artifacts
pattern: digest-*
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
- name: Require matching acceptance on Cabbage and DigestBox
if: env.RELEASE_BRANCH == 'main'
env:
GH_TOKEN: ${{ github.token }}
STAGING_DOCKER_ARTIFACT_DIR: ${{ runner.temp }}/tested-docker-artifacts
run: uv run --no-project python bin/verify-staging-release.py
- name: Resolve release binaries through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
+81
View File
@@ -0,0 +1,81 @@
name: Staging acceptance
on:
workflow_run:
workflows: [Release]
types: [completed]
branches: [dev]
permissions: {}
concurrency:
group: staging-acceptance
cancel-in-progress: false
jobs:
published:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'
runs-on: ubuntu-24.04
permissions:
actions: read
outputs:
ready: ${{ steps.image.outputs.ready }}
steps:
- id: image
env:
GH_TOKEN: ${{ github.token }}
RELEASE_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
set -Eeuo pipefail
ready=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/jobs?per_page=100" --jq 'any(.jobs[]; .name == "Publish tested multiarch image" and .conclusion == "success")')
echo "ready=$ready" >> "$GITHUB_OUTPUT"
acceptance:
# The privileged key is never exposed to PRs or arbitrary dispatch refs.
needs: published
if: needs.published.outputs.ready == 'true'
runs-on: [self-hosted, Linux, X64, ugnas-linux]
environment: staging
timeout-minutes: 110
permissions:
contents: read
deployments: write
strategy:
fail-fast: false
matrix:
target: [cabbage, digestbox]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e
with:
version: '0.11.3'
- name: Prepare browser and restricted staging identity
env:
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
STAGING_KNOWN_HOSTS: ${{ secrets.STAGING_KNOWN_HOSTS }}
run: |
set -Eeuo pipefail
umask 077
printf '%s\n' "$STAGING_SSH_KEY" > "$RUNNER_TEMP/staging-key"
printf '%s\n' "$STAGING_KNOWN_HOSTS" > "$RUNNER_TEMP/staging-known-hosts"
uv run --no-project --with playwright==1.63.0 playwright install chromium
- name: Verify actual deployment, full captures and rendered snapshots
env:
GH_TOKEN: ${{ github.token }}
STAGING_TARGET: ${{ matrix.target }}
STAGING_SHA: ${{ github.event.workflow_run.head_sha }}
STAGING_KEY_FILE: ${{ runner.temp }}/staging-key
STAGING_KNOWN_HOSTS_FILE: ${{ runner.temp }}/staging-known-hosts
run: uv run --no-project --with playwright==1.63.0 python bin/check-staging-deployment.py
- name: Remove staging identity
if: always()
run: rm -f "$RUNNER_TEMP/staging-key" "$RUNNER_TEMP/staging-known-hosts"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: staging-${{ matrix.target }}-${{ github.event.workflow_run.head_sha }}
path: staging-evidence/
if-no-files-found: error
+8 -1
View File
@@ -63,7 +63,14 @@ def setup_django(check_db=False) -> None:
os.chown(CONSTANTS.DATA_DIR, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
if CONSTANTS.DATA_DIR.exists():
for child in CONSTANTS.DATA_DIR.iterdir():
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
try:
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
except FileNotFoundError:
# A concurrent SQLite transaction can remove its journal
# between directory enumeration and chown. There is no
# ownership left to fix; other permission errors must
# still fail startup rather than being hidden.
continue
# Suppress the "database access during app initialization" warning
# This warning can be triggered during django.setup() but is safe to ignore
@@ -100,11 +100,12 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
if schema_editor.connection.vendor != "sqlite":
return
from django.db import connection
from django.utils import timezone
import uuid as uuid_lib
from datetime import datetime
connection = schema_editor.connection
cursor = connection.cursor()
now = connection.ops.adapt_datetimefield_value(timezone.now())
# Check if there are any snapshots without a crawl
cursor.execute("SELECT COUNT(*) FROM core_snapshot WHERE crawl_id IS NULL")
@@ -125,14 +126,13 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
INSERT INTO auth_user (id, password, is_superuser, username, first_name, last_name, email, is_staff, is_active, date_joined)
VALUES (1, '!', 1, 'system', '', '', '', 1, 1, %s)
""",
[datetime.now().isoformat()],
[now],
)
# Create a default crawl for migrated snapshots.
# Depending on migration graph order, later crawls migrations may already
# have removed output_dir by the time this data migration runs.
crawl_id = uuid_lib.uuid4().hex
now = datetime.now().isoformat()
cursor.execute("PRAGMA table_info(crawls_crawl)")
crawl_columns = {row[1] for row in cursor.fetchall()}
default_columns: list[str] = []
@@ -155,9 +155,9 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
status, retry_at, created_by_id, schedule_id, config, persona_id
) VALUES (%s, %s, %s, 0, 0, %s, 0, '', 'Migrated from v0.7.2/v0.8.6',
'Auto-created crawl for migrated snapshots', {default_values_sql}
'sealed', %s, 1, NULL, '{{}}', NULL)
'sealed', NULL, 1, NULL, '{{}}', NULL)
""",
[crawl_id, now, now, crawl_urls, now],
[crawl_id, now, now, crawl_urls],
)
# Assign all snapshots without a crawl to the default crawl
@@ -0,0 +1,60 @@
"""Repair SQLite queue timestamps written as raw ISO strings by old migrations."""
from django.db import migrations
from django.utils.dateparse import parse_datetime
def repair_legacy_retry_timestamps(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
with connection.cursor() as cursor:
# 0024 created this Crawl already sealed, but left a due retry marker.
# Its orphaned snapshots have their own scheduler rows for maintenance.
cursor.execute(
"""
UPDATE crawls_crawl SET retry_at = NULL
WHERE status = 'sealed'
AND label = 'Migrated from v0.7.2/v0.8.6'
AND notes = 'Auto-created crawl for migrated snapshots'
AND substr(retry_at, 11, 1) = 'T'
""",
)
# SQLite compares stored date strings lexically. A raw `T` timestamp
# from a legacy migration sorts differently from Django's space-separated
# parameter, so a due row can be selected but never claimed. Preserve
# the datetime Django already reads from each row while rewriting its
# storage representation through the active database adapter.
for table in ("crawls_crawl", "core_snapshot", "machine_binary", "machine_process"):
last_id = ""
while True:
cursor.execute(
f"SELECT id, CAST(retry_at AS TEXT) FROM {table} WHERE id > %s AND substr(retry_at, 11, 1) = 'T' ORDER BY id LIMIT 500",
[last_id],
)
rows = cursor.fetchall()
if not rows:
break
for row_id, raw_retry_at in rows:
try:
parsed = parse_datetime(raw_retry_at)
except ValueError:
continue
if parsed is None:
continue
normalized = connection.ops.adapt_datetimefield_value(parsed)
cursor.execute(
f"UPDATE {table} SET retry_at = %s WHERE id = %s AND retry_at = %s",
[normalized, row_id, raw_retry_at],
)
last_id = rows[-1][0]
class Migration(migrations.Migration):
dependencies = [
("core", "0054_snapshot_fs_version_index"),
]
operations = [migrations.RunPython(repair_legacy_retry_timestamps, migrations.RunPython.noop)]
@@ -1904,12 +1904,22 @@
const progressEndpoint = monitor.dataset.progressEndpoint || '/progress.json';
function fetchProgress() {
// Opening a replay replaces the queued snapshot's progress panel.
// Its interval and already-running fetch must stop using that old DOM.
if (!monitor.isConnected) {
stopPolling();
return;
}
fetch(progressEndpoint, { credentials: 'same-origin' })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
if (!monitor.isConnected) {
stopPolling();
return;
}
if (data.error) {
console.error('Progress API error:', data.error, data.traceback);
setOrchestratorState('error', 'Backend not responding');
@@ -1921,6 +1931,10 @@
updateProgress(data);
})
.catch(error => {
if (!monitor.isConnected) {
stopPolling();
return;
}
console.error('Progress fetch error:', error);
setOrchestratorState('error', 'Backend not responding');
idleMessage.textContent = 'Fetch Error: ' + error.message;
@@ -1929,7 +1943,7 @@
}
function startPolling() {
if (pollInterval) return;
if (pollInterval || !monitor.isConnected) return;
fetchProgress();
pollInterval = setInterval(fetchProgress, pollDelayMs);
}
@@ -2033,7 +2047,13 @@
// Start polling when page loads
startPolling();
setInterval(updateDurationBadges, 1000);
const durationInterval = setInterval(() => {
if (!monitor.isConnected) {
clearInterval(durationInterval);
return;
}
updateDurationBadges();
}, 1000);
// Pause polling when tab is hidden
document.addEventListener('visibilitychange', function() {
+129 -2
View File
@@ -2,9 +2,14 @@
import math
import os
import re
import stat
import sys
import time
from pathlib import Path
import psutil
RESOURCE_RECHECK_SECONDS = 2.0
# Scheduling policy, not a minimum-RAM claim: stop increasing extraction
@@ -12,6 +17,65 @@ RESOURCE_RECHECK_SECONDS = 2.0
# stalls. Require both recent and rolling pressure so transient reclaim does
# not indefinitely starve otherwise viable work on small hosts.
MEMORY_FULL_STALL_PERCENT = 10.0
ZRAM_DEVICE_NAME = re.compile(r"zram\d+")
def zram_swap_identifiers(sys_block: Path = Path("/sys/block")) -> set[str]:
"""Return zram block names and major:minor IDs known to sysfs."""
identifiers = set()
for device in sys_block.glob("zram*"):
if ZRAM_DEVICE_NAME.fullmatch(device.name):
identifiers.add(device.name)
try:
identifiers.add((device / "dev").read_text().strip())
except OSError:
pass
return identifiers
def swap_source_identifiers(filename: str) -> set[str]:
"""Identify a swap source by its visible name and, for block devices, ID."""
identifiers = {Path(filename).name}
try:
source = os.stat(filename)
if stat.S_ISBLK(source.st_mode):
identifiers.add(f"{os.major(source.st_rdev)}:{os.minor(source.st_rdev)}")
except OSError:
pass
return identifiers
def disk_backed_swap_free_bytes(swaps: str, zram_identifiers: set[str]) -> int:
"""Sum free active swap except zram, whose capacity is already backed by RAM."""
lines = swaps.splitlines()
free_kib = 0
for line in lines[1:]: # /proc/swaps starts with a column header
fields = line.split()
if len(fields) < 5:
continue
filename, _, size_kib, used_kib, _ = fields[:5]
source_ids = swap_source_identifiers(filename)
if source_ids & zram_identifiers or any(ZRAM_DEVICE_NAME.fullmatch(name) for name in source_ids):
continue
try:
free_kib += max(0, int(size_kib) - int(used_kib))
except ValueError:
continue
return free_kib * 1024
def host_swap_free_bytes(meminfo_swap_free: int) -> int:
"""Use only independently backed swap as extra capacity on Linux."""
if not sys.platform.startswith("linux"):
return psutil.swap_memory().free
try:
swaps = Path("/proc/swaps").read_text()
except OSError:
# If Linux swap sources cannot be classified, fail closed rather than
# treating potentially RAM-backed zram capacity as extra headroom.
return 0
disk_free = disk_backed_swap_free_bytes(swaps, zram_swap_identifiers())
return min(max(0, meminfo_swap_free), disk_free)
class ResourceAdmission:
@@ -44,10 +108,73 @@ class ResourceAdmission:
cpus = min(cpus, max(1, math.ceil(int(quota) / int(period))))
except (OSError, ValueError, ZeroDivisionError):
continue
# CPU quota limits parallel admission, not forward progress. A host
# with a fractional CPU must still be able to run one snapshot.
# CPU quota bounds parallel work; measured memory admission is done
# only after the first complete snapshot establishes its actual cost.
return max(1, min(configured, cpus))
def additional_snapshot_slots(self, configured: int, active: int, observed_cost: int | None) -> int:
if not observed_cost:
# Stage one complete real capture before spending memory on parallel
# hook trees. A zero-cost/failed probe cannot justify fanout.
return 1 if active == 0 and configured > 0 else 0
headroom = self.memory_headroom()
return self.slots_for_headroom(configured, active, observed_cost, headroom[1] if headroom else 0)
@staticmethod
def slots_for_headroom(configured: int, active: int, observed_cost: int, available: int) -> int:
slots = max(0, configured - active)
if observed_cost > 0:
# Active captures can still grow to the measured peak. Reserve for
# that growth before promising the remaining headroom to new work.
unpromised = max(0, available - active * observed_cost)
slots = min(slots, unpromised // observed_cost)
return max(1, slots) if active == 0 and configured else slots
def memory_headroom(self) -> tuple[int, int, int] | None:
"""Return workload RAM+swap usage, effective headroom, and host headroom."""
try:
meminfo = {
name: int(value.split()[0]) * 1024
for name, value in (line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() if ":" in line)
}
host_swap_free = host_swap_free_bytes(meminfo.get("SwapFree", 0))
host_available = meminfo["MemAvailable"] + host_swap_free
except (OSError, ValueError, KeyError):
host_swap_free = 0 if sys.platform.startswith("linux") else psutil.swap_memory().free
host_available = psutil.virtual_memory().available + host_swap_free
available = host_available
used_bytes = None
for path in self.cgroup_paths():
try:
memory_max = (path / "memory.max").read_text().strip()
if memory_max == "max":
continue
current = int((path / "memory.current").read_text())
swap_max = (path / "memory.swap.max").read_text().strip()
swap_current = int((path / "memory.swap.current").read_text())
swap_room = host_swap_free if swap_max == "max" else min(host_swap_free, max(0, int(swap_max) - swap_current))
available = min(available, max(0, int(memory_max) - current) + swap_room)
if used_bytes is None:
used_bytes = current + swap_current
except (OSError, ValueError, KeyError):
continue
if used_bytes is None:
# Bare-metal and non-Linux installs have no finite cgroup. Count
# the real runner process tree rather than the entire host.
try:
process = psutil.Process()
processes = [process, *process.children(recursive=True)]
except (psutil.Error, OSError):
return None
used_bytes = 0
for child in processes:
try:
used_bytes += child.memory_info().rss
except (psutil.Error, OSError):
continue
return used_bytes, available, host_available
def observe_memory_stalls(self, source: str, total: int, avg10: float, sampled_at: float) -> bool:
previous = self._memory_stalls.get(source)
self._memory_stalls[source] = (total, sampled_at)
+60 -12
View File
@@ -232,6 +232,10 @@ class CrawlRunner:
self.snapshot_tasks: dict[str, asyncio.Task[None]] = {}
self.snapshot_semaphore = asyncio.Semaphore(1)
self.max_concurrent_snapshots = 1
self._warmup_snapshot_id: str | None = None
self._memory_baseline: tuple[int, int] | None = None
self._memory_peak_cost = 0
self._observed_snapshot_cost: int | None = None
self._resource_deferred = False
self._initial_snapshot_admitted = False
self.persona = None
@@ -498,10 +502,15 @@ class CrawlRunner:
if task is not None and not task.done():
return
current_event = crawl_start_event or get_current_event()
if not isinstance(current_event, CrawlStartEvent) and in_handler_context():
return
if self._observed_snapshot_cost is None and self._warmup_snapshot_id is None:
observation = resource_admission.memory_headroom()
self._memory_baseline = (observation[0], observation[2]) if observation is not None else None
self._memory_peak_cost = 0
self._warmup_snapshot_id = snapshot_id
if isinstance(current_event, CrawlStartEvent):
task = asyncio.create_task(self.run_snapshot(snapshot_id, current_event), context=_runner_task_context())
elif in_handler_context():
return
else:
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
self._initial_snapshot_admitted = False
@@ -530,6 +539,7 @@ class CrawlRunner:
if task.done():
if self.snapshot_tasks.get(snapshot_id) is task:
self.snapshot_tasks.pop(snapshot_id, None)
self._finish_warmup_snapshot(snapshot_id)
try:
task.result()
except asyncio.CancelledError as err:
@@ -554,13 +564,16 @@ class CrawlRunner:
return
continue
await self.heartbeat_active_leases()
done, _pending = await asyncio.wait(pending_tasks, timeout=10.0, return_when=asyncio.FIRST_COMPLETED)
self._observe_snapshot_memory()
done, _pending = await asyncio.wait(pending_tasks, timeout=RESOURCE_RECHECK_SECONDS, return_when=asyncio.FIRST_COMPLETED)
self._observe_snapshot_memory()
if not done:
continue
for task in done:
for snapshot_id, tracked_task in list(self.snapshot_tasks.items()):
if tracked_task is task:
self.snapshot_tasks.pop(snapshot_id, None)
self._finish_warmup_snapshot(snapshot_id)
break
try:
task.result()
@@ -578,6 +591,36 @@ class CrawlRunner:
if not stop_scheduling and enqueue_projected:
await self.enqueue_pending_snapshots_from_projection()
def _observe_snapshot_memory(self) -> None:
if self._memory_baseline is None:
return
observation = resource_admission.memory_headroom()
if observation is not None:
used, _available, host_available = observation
baseline_used, baseline_host_available = self._memory_baseline
# Host headroom sees helpers outside ArchiveBox's cgroup. Keep a
# high-water cost as later URLs may use more than the first one.
# Never divide concurrent growth by task count: their pages vary.
self._memory_peak_cost = max(
self._memory_peak_cost,
used - baseline_used,
baseline_host_available - host_available,
)
if self._observed_snapshot_cost is not None:
self._observed_snapshot_cost = max(self._observed_snapshot_cost, self._memory_peak_cost)
def _finish_warmup_snapshot(self, snapshot_id: str) -> None:
if snapshot_id != self._warmup_snapshot_id or self._observed_snapshot_cost is not None:
return
self._observe_snapshot_memory()
if self._memory_peak_cost > 0:
self._observed_snapshot_cost = self._memory_peak_cost
else:
# No observed cost cannot justify parallel admission. Probe the
# next real snapshot while the queue keeps moving one at a time.
self._memory_baseline = None
self._warmup_snapshot_id = None
async def heartbeat_active_leases(self) -> None:
# These are resumable work-item leases, not orchestrator-election
# heartbeats. Each update is a short autocommit statement; network and
@@ -645,7 +688,11 @@ class CrawlRunner:
self.max_concurrent_snapshots = resource_admission.snapshot_limit(int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
available_slots = max(0, self.max_concurrent_snapshots - len(active_snapshot_ids))
available_slots = resource_admission.additional_snapshot_slots(
self.max_concurrent_snapshots,
len(active_snapshot_ids),
self._observed_snapshot_cost,
)
if available_slots <= 0:
return
pending_snapshot_ids = await sync_to_async(
@@ -997,7 +1044,8 @@ class CrawlRunner:
if self.initial_snapshot_ids is not None:
remaining_snapshot_ids = iter(snapshot_ids)
while True:
batch = list(zip(range(self.max_concurrent_snapshots), remaining_snapshot_ids, strict=False))
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
batch = list(zip(range(slots), remaining_snapshot_ids, strict=False))
if not batch:
return
for _slot, snapshot_id in batch:
@@ -1008,7 +1056,8 @@ class CrawlRunner:
await self.enqueue_snapshot(snapshot_id)
await self.wait_for_snapshot_tasks(enqueue_projected=False)
else:
for snapshot_id in snapshot_ids[: self.max_concurrent_snapshots]:
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
for snapshot_id in snapshot_ids[:slots]:
if await self.crawl_is_cancelled():
break
if await self.crawl_is_paused():
@@ -1471,7 +1520,9 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
# avoids hot-looping on the parent while child work is ready without
# resurrecting a user cancellation that sealed the crawl after
# selection.
crawl.safe_update(
if crawl.status != crawl.StatusChoices.STARTED:
return False
return crawl.safe_update(
{
"status": crawl.StatusChoices.STARTED,
"retry_at": now + timedelta(seconds=ACTIVE_STATE_LEASE_SECONDS),
@@ -1480,7 +1531,6 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
refresh=False,
extra_filter={"status": crawl.StatusChoices.STARTED},
)
return True
if snapshot_count and not due_active_snapshots:
if crawl.is_finished():
if not crawl.claim_processing_lock(lock_seconds=lock_seconds):
@@ -1838,12 +1888,11 @@ def _run_due_crawl_status(
due_crawl = Crawl.objects.filter(id=due_crawl_id).first()
if due_crawl is None:
return True
run_due_crawl(
return run_due_crawl(
due_crawl,
lock_seconds=lock_seconds,
interactive_interrupts=interactive_interrupts,
)
return True
def _run_due_snapshot_query(
@@ -1897,8 +1946,7 @@ def _run_due_binary() -> bool:
due_binary = Binary.objects.filter(id=due_binary_id).first()
if due_binary is None:
return True
run_due_binary(due_binary, lock_seconds=60)
return True
return run_due_binary(due_binary, lock_seconds=60)
def run_pending_crawls(
@@ -12,12 +12,18 @@ Migration tests from 0.8.x to 0.9.x.
import sqlite3
import json
import signal
import subprocess
import time
import uuid
from datetime import datetime
from urllib.parse import urlparse
import psutil
import pytest
from archivebox.tests.conftest import cli_env, run_archivebox_cmd, stop_archivebox_process
from .migrations_helpers import (
SCHEMA_0_7,
SCHEMA_0_8,
@@ -225,6 +231,118 @@ def test_migration_preserves_crawls(migration_08_data):
assert ok, msg
def test_legacy_default_crawl_is_sealed_without_a_due_retry(tmp_path):
"""Upgrading a legacy collection must not leave its sealed crawl runnable."""
work_dir = tmp_path
db_path = work_dir / "index.sqlite3"
create_data_dir_structure(work_dir)
with sqlite3.connect(db_path) as conn:
conn.executescript(SCHEMA_0_7)
seed_0_7_data(db_path)
result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=90)
assert result.returncode == 0, result.stderr or result.stdout
with sqlite3.connect(db_path) as conn:
rows = conn.execute("SELECT status, retry_at FROM crawls_crawl").fetchall()
snapshot_statuses = conn.execute("SELECT DISTINCT status FROM core_snapshot").fetchall()
result_statuses = conn.execute("SELECT DISTINCT status FROM core_archiveresult").fetchall()
assert rows == [("sealed", None)]
assert snapshot_statuses == [("sealed",)]
assert set(result_statuses) == {("succeeded",), ("failed",), ("skipped",)}
def test_old_migrated_crawl_stays_idle_and_forward_migration_repairs_it(tmp_path):
"""A dated raw ISO retry from an already-upgraded collection must not spin."""
work_dir = tmp_path
db_path = work_dir / "index.sqlite3"
create_data_dir_structure(work_dir)
with sqlite3.connect(db_path) as conn:
conn.executescript(SCHEMA_0_7)
seed_0_7_data(db_path)
before_repair = run_archivebox_migration_cmd(work_dir, ["manage", "migrate", "core", "0054", "--noinput"], timeout=90)
assert before_repair.returncode == 0, before_repair.stderr or before_repair.stdout
with sqlite3.connect(db_path) as conn:
# 0024 used datetime.now().isoformat() for this sealed Crawl. Represent
# a collection upgraded before today so SQLite's lexical due query sees it.
conn.execute("UPDATE crawls_crawl SET retry_at = '2024-01-01T00:00:00.123456'")
assert conn.execute("SELECT status, retry_at FROM crawls_crawl").fetchall() == [
("sealed", "2024-01-01T00:00:00.123456"),
]
assert conn.execute("SELECT DISTINCT status FROM core_snapshot").fetchall() == [("sealed",)]
env = cli_env(disable_extractors=True)
log_path = work_dir / "idle-daemon.log"
with log_path.open("w") as log:
daemon = run_archivebox_cmd(
["run", "--daemon"],
cwd=work_dir,
env=env,
stdout=log,
stderr=subprocess.STDOUT,
capture_output=False,
wait=False,
start_new_session=True,
)
try:
ready_deadline = time.monotonic() + 20
while True:
with sqlite3.connect(db_path) as conn:
ready = conn.execute(
"SELECT 1 FROM machine_process WHERE process_type = 'orchestrator' AND status = 'running' AND pid = ?",
(daemon.pid,),
).fetchone()
if ready:
break
assert daemon.poll() is None, log_path.read_text()
assert time.monotonic() < ready_deadline, log_path.read_text()
time.sleep(0.1)
cpu_percent = psutil.Process(daemon.pid).cpu_percent(interval=5)
assert cpu_percent < 30, f"idle daemon used {cpu_percent}% CPU: {log_path.read_text()}"
finally:
stop_archivebox_process(daemon, signal.SIGTERM)
repaired = run_archivebox_migration_cmd(work_dir, ["init"], timeout=90)
assert repaired.returncode == 0, repaired.stderr or repaired.stdout
with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT status, retry_at FROM crawls_crawl").fetchall() == [("sealed", None)]
def test_forward_migration_normalizes_active_legacy_retry_timestamps(migration_08_data):
"""Preserve instants for active rows while leaving canonical timestamps intact."""
work_dir, db_path, _original_data = migration_08_data
before_repair = run_archivebox_migration_cmd(work_dir, ["manage", "migrate", "core", "0054", "--noinput"], timeout=90)
assert before_repair.returncode == 0, before_repair.stderr or before_repair.stdout
old_offset_timestamp = "2024-01-01T03:04:05.123456+03:00"
expected_utc_timestamp = "2024-01-01 00:04:05.123456"
canonical_timestamp = "2025-04-05 06:07:08.123456"
with sqlite3.connect(db_path) as conn:
crawl_ids = [row[0] for row in conn.execute("SELECT id FROM crawls_crawl WHERE status = 'queued' ORDER BY id")]
snapshot_ids = [row[0] for row in conn.execute("SELECT id FROM core_snapshot ORDER BY id")]
binary_id = conn.execute("SELECT id FROM machine_binary ORDER BY id LIMIT 1").fetchone()[0]
process_id = conn.execute("SELECT id FROM machine_process ORDER BY id LIMIT 1").fetchone()[0]
assert len(crawl_ids) == 2
assert len(snapshot_ids) >= 2
conn.execute("UPDATE crawls_crawl SET retry_at = ? WHERE id = ?", (old_offset_timestamp, crawl_ids[0]))
conn.execute("UPDATE crawls_crawl SET retry_at = ? WHERE id = ?", (canonical_timestamp, crawl_ids[1]))
conn.execute("UPDATE core_snapshot SET retry_at = ? WHERE id = ?", (old_offset_timestamp, snapshot_ids[0]))
conn.execute("UPDATE core_snapshot SET retry_at = ? WHERE id = ?", (canonical_timestamp, snapshot_ids[1]))
conn.execute("UPDATE machine_binary SET retry_at = ? WHERE id = ?", (old_offset_timestamp, binary_id))
conn.execute("UPDATE machine_process SET retry_at = ? WHERE id = ?", (old_offset_timestamp, process_id))
repaired = run_archivebox_migration_cmd(work_dir, ["init"], timeout=90)
assert repaired.returncode == 0, repaired.stderr or repaired.stdout
with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT retry_at FROM crawls_crawl WHERE id = ?", (crawl_ids[0],)).fetchone() == (expected_utc_timestamp,)
assert conn.execute("SELECT retry_at FROM crawls_crawl WHERE id = ?", (crawl_ids[1],)).fetchone() == (canonical_timestamp,)
assert conn.execute("SELECT retry_at FROM core_snapshot WHERE id = ?", (snapshot_ids[0],)).fetchone() == (expected_utc_timestamp,)
assert conn.execute("SELECT retry_at FROM core_snapshot WHERE id = ?", (snapshot_ids[1],)).fetchone() == (canonical_timestamp,)
assert conn.execute("SELECT retry_at FROM machine_binary WHERE id = ?", (binary_id,)).fetchone() == (expected_utc_timestamp,)
assert conn.execute("SELECT retry_at FROM machine_process WHERE id = ?", (process_id,)).fetchone() == (expected_utc_timestamp,)
def test_migration_preserves_snapshot_crawl_links(migration_08_data):
"""Migration should preserve snapshot-to-crawl relationships and assign default crawl to orphans."""
work_dir, db_path, original_data = migration_08_data
+54
View File
@@ -724,6 +724,60 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel(
assert overlapping, f"Expected hook processes from different snapshots to overlap, got intervals: {intervals}"
@pytest.mark.timeout(600)
def test_direct_url_crawl_measures_first_snapshot_before_parallel_admission(initialized_archive, recursive_test_site):
"""A real three-URL capture must not launch all Chrome hook trees at once."""
urls = [recursive_test_site["root_url"], *recursive_test_site["child_urls"][:2]]
env = os.environ.copy()
env.update(
{
"USE_COLOR": "false",
"SHOW_PROGRESS": "false",
"URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
"ABXPKG_LIB_DIR": str(initialized_archive / "lib"),
"CHROME_HEADLESS": "true",
"CHROME_SANDBOX": "false",
"CHROME_ISOLATION": "crawl",
"CRAWL_MAX_CONCURRENT_SNAPSHOTS": "3",
"TIMEOUT": "90",
},
)
result = run_archivebox_cmd(
["add", "--parser=url_list", "--plugins=chrome,wget,screenshot", "--tag=measured-admission", *urls],
cwd=initialized_archive,
env=env,
timeout=600,
)
assert result.returncode == 0, result.stderr or result.stdout
with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get(tags_str="measured-admission")
snapshots = list(Snapshot.objects.filter(crawl=crawl).order_by("created_at"))
hook_intervals = list(
Process.objects.filter(
process_type=Process.TypeChoices.HOOK,
archiveresult__snapshot__crawl=crawl,
)
.values_list("archiveresult__snapshot_id", "started_at", "ended_at")
.distinct(),
)
assert len(snapshots) == 3
assert all(snapshot.status == Snapshot.StatusChoices.SEALED and snapshot.downloaded_at for snapshot in snapshots)
first_id = snapshots[0].id
first_end = snapshots[0].downloaded_at
later_starts = [started for snapshot_id, started, _ended in hook_intervals if snapshot_id != first_id and started]
assert later_starts and first_end <= min(later_starts), hook_intervals
later_intervals = [
(snapshot_id, started, ended) for snapshot_id, started, ended in hook_intervals if snapshot_id != first_id and started and ended
]
assert any(
left_id != right_id and left_start < right_end and right_start < left_end
for index, (left_id, left_start, left_end) in enumerate(later_intervals)
for right_id, right_start, right_end in later_intervals[index + 1 :]
), later_intervals
def test_crawl_snapshot_has_parent_snapshot_field(tmp_path, initialized_archive):
"""Test that Snapshot model has parent_snapshot field."""
@@ -0,0 +1,174 @@
import json
import importlib.util
from pathlib import Path
import re
import subprocess
import sys
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
FINGERPRINT_SCRIPT = REPO_ROOT / "bin" / "release-source-fingerprint.py"
_SPEC = importlib.util.spec_from_file_location("release_source_fingerprint", FINGERPRINT_SCRIPT)
assert _SPEC is not None and _SPEC.loader is not None
_FINGERPRINT_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_FINGERPRINT_MODULE)
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _commit(repo: Path, message: str) -> str:
_git(repo, "add", "-A")
_git(repo, "commit", "-m", message)
return _git(repo, "rev-parse", "HEAD")
def _fingerprint(repo: Path, revision: str) -> str:
return subprocess.run(
[sys.executable, str(FINGERPRINT_SCRIPT), revision],
cwd=repo,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _write_template_tree(repo: Path) -> None:
(repo / "etc").mkdir()
project = (REPO_ROOT / "pyproject.toml").read_text()
project = re.sub(r'^version = "[^"]+"$', 'version = "0.9.56rc41"', project, count=1, flags=re.M)
project = re.sub(r'^current_version = "[^"]+"$', 'current_version = "v0.9.56rc41"', project, count=1, flags=re.M)
assert 'version = "0.9.56rc41"' in project
assert 'current_version = "v0.9.56rc41"' in project
lock, count = re.subn(
r'(\[\[package\]\]\nname = "archivebox"\nversion = ")[^"]+',
r"\g<1>0.9.56rc41",
(REPO_ROOT / "uv.lock").read_text(),
count=1,
)
assert count == 1
package = json.loads((REPO_ROOT / "etc/package.json").read_text())
package["version"] = "0.9.56rc41"
(repo / "pyproject.toml").write_text(project)
(repo / "uv.lock").write_text(lock)
(repo / "etc/package.json").write_text(json.dumps(package, indent=2) + "\n")
(repo / "source.py").write_text("release behavior = unchanged\n")
def _new_repo(tmp_path: Path) -> tuple[Path, str]:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.name", "ArchiveBox Tests")
_git(repo, "config", "user.email", "tests@archivebox.io")
_write_template_tree(repo)
return repo, _commit(repo, "stable acceptance source")
def test_fingerprint_ignores_only_archivebox_release_version_fields(tmp_path):
repo, accepted = _new_repo(tmp_path)
project = repo / "pyproject.toml"
text = project.read_text()
text = re.sub(r'^version = "[^"]+"$', 'version = "0.9.64"', text, count=1, flags=re.M)
text = re.sub(r'^current_version = "[^"]+"$', 'current_version = "v0.9.64"', text, count=1, flags=re.M)
project.write_text(text)
lock = repo / "uv.lock"
lock.write_text(re.sub(r'(?m)^(version = ")0\.9\.56rc41("\s*)$', r"\g<1>0.9.64\2", lock.read_text(), count=1))
package = repo / "etc/package.json"
package.write_text(package.read_text().replace('"version": "0.9.56rc41"', '"version": "0.9.64"'))
stable = _commit(repo, "stable version bump")
assert _fingerprint(repo, accepted) == _fingerprint(repo, stable)
def test_pyproject_version_normalization_matches_exact_template_bytes():
project = (REPO_ROOT / "pyproject.toml").read_bytes()
normalized = _FINGERPRINT_MODULE.normalize_file(b"pyproject.toml", project)
expected = re.sub(
rb'(?m)^(version = ")[^"\r\n]+("\s*(?:#.*)?\r?\n?)$',
lambda match: match.group(1) + b"<ARCHIVEBOX_PROJECT_VERSION>" + match.group(2),
project,
count=1,
)
expected = re.sub(
rb'(?m)^(current_version = ")[^"\r\n]+("\s*(?:#.*)?\r?\n?)$',
lambda match: match.group(1) + b"<ARCHIVEBOX_PROJECT_VERSION>" + match.group(2),
expected,
count=1,
)
assert normalized == expected
@pytest.mark.parametrize(
"change",
[
"source",
"project_dependency",
"lock_dependency",
"package_dependency",
"other_tool_version",
"file_mode",
],
)
def test_fingerprint_detects_non_release_version_changes(tmp_path, change):
repo, accepted = _new_repo(tmp_path)
if change == "source":
(repo / "source.py").write_text("release behavior = changed\n")
elif change == "project_dependency":
path = repo / "pyproject.toml"
project = path.read_text()
project, count = re.subn(r'("abx-dl==)[^"]+(")', r"\g<1>999.0\2", project, count=1)
assert count == 1
path.write_text(project)
elif change == "lock_dependency":
path = repo / "uv.lock"
path.write_text(re.sub(r'(?m)(^name = "abx-dl"\nversion = ")[^"]+', r"\g<1>999.0", path.read_text(), count=1))
elif change == "package_dependency":
path = repo / "etc/package.json"
payload = json.loads(path.read_text())
name = next(iter(payload["dependencies"]))
payload["dependencies"][name] = "999.0"
path.write_text(json.dumps(payload, indent=2) + "\n")
elif change == "other_tool_version":
path = repo / "pyproject.toml"
path.write_text(path.read_text() + '\n[tool.other]\nversion = "1.0"\n')
_commit(repo, "add unrelated tool version")
path.write_text(path.read_text().replace('version = "1.0"', 'version = "2.0"'))
elif change == "file_mode":
path = repo / "source.py"
path.chmod(0o755)
changed = _commit(repo, change)
assert _fingerprint(repo, accepted) != _fingerprint(repo, changed)
def test_fingerprint_detects_submodule_gitlink_changes(tmp_path):
repo, accepted = _new_repo(tmp_path)
subrepo = tmp_path / "dependency"
subrepo.mkdir()
subprocess.run(["git", "init", "-q", str(subrepo)], check=True)
_git(subrepo, "config", "user.name", "ArchiveBox Tests")
_git(subrepo, "config", "user.email", "tests@archivebox.io")
(subrepo / "dependency.txt").write_text("one\n")
first = _commit(subrepo, "first dependency revision")
(repo / "vendor").mkdir()
_git(repo, "update-index", "--add", "--cacheinfo", f"160000,{first},vendor/dependency")
_git(repo, "commit", "-m", "add dependency gitlink")
one = _git(repo, "rev-parse", "HEAD")
(subrepo / "dependency.txt").write_text("two\n")
second = _commit(subrepo, "second dependency revision")
_git(repo, "update-index", "--cacheinfo", f"160000,{second},vendor/dependency")
_git(repo, "commit", "-m", "update dependency gitlink")
two = _git(repo, "rev-parse", "HEAD")
assert _fingerprint(repo, accepted) != _fingerprint(repo, one)
assert _fingerprint(repo, one) != _fingerprint(repo, two)
+57
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import json
import importlib.util
import shlex
import os
import re
@@ -13,6 +14,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
RELEASE_CANDIDATE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release-candidate.yml"
PIP_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pip.yml"
DOCKER_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "docker.yml"
VERIFY_STAGING_SCRIPT = REPO_ROOT / "bin" / "verify-staging-release.py"
_VERIFY_SPEC = importlib.util.spec_from_file_location("verify_staging_release", VERIFY_STAGING_SCRIPT)
assert _VERIFY_SPEC is not None and _VERIFY_SPEC.loader is not None
_VERIFY_STAGING = importlib.util.module_from_spec(_VERIFY_SPEC)
_VERIFY_SPEC.loader.exec_module(_VERIFY_STAGING)
def _git(repo, *args):
@@ -168,6 +175,7 @@ def test_release_uses_registered_publisher_and_authorized_tag_credentials():
assert python_release["environment"] == "pypi"
checkout = python_release["steps"][0]
assert checkout["with"]["token"] == "${{ secrets.RELEASE_GH_TOKEN || github.token }}"
assert docker_release["needs"] == ["candidate", "python-release"]
assert docker_release["env"]["DOCKER_DIGEST_RUN_ID"] == "${{ needs.candidate.outputs.digest_run_id }}"
assert "release_ready" not in docker_release["if"]
@@ -245,3 +253,52 @@ def test_release_uses_registered_publisher_and_authorized_tag_credentials():
create_release_line = logical_lines.index(next(line for line in logical_lines if line.startswith("$GH_BINARY release create ")))
upload_release_line = logical_lines.index(next(line for line in logical_lines if line.startswith("$GH_BINARY release upload ")))
assert publish_line < rc_skip_line < create_release_line < upload_release_line
def test_stable_publication_requires_live_acceptance_before_upload():
workflow = yaml.safe_load(RELEASE_WORKFLOW.read_text())
publisher = workflow["jobs"]["python-release"]
steps = publisher["steps"]
gate = next(i for i, step in enumerate(steps) if step.get("name") == "Require matching acceptance on Cabbage and DigestBox")
upload = next(i for i, step in enumerate(steps) if step.get("name") == "Publish the exact tested distributions")
assert gate < upload
# The same guard applies to automatic and manually requested stable releases.
assert steps[gate]["if"] == "env.RELEASE_BRANCH == 'main'"
assert "verify-staging-release.py" in steps[gate]["run"]
assert not steps[gate].get("continue-on-error")
assert publisher["permissions"]["deployments"] == "read"
def test_staging_gate_matches_the_exact_tested_docker_base_digest(tmp_path):
workflow = yaml.safe_load(RELEASE_WORKFLOW.read_text())
publisher = workflow["jobs"]["python-release"]
steps = publisher["steps"]
metadata = next(step for step in steps if step.get("name") == "Download tested Docker base-image metadata")
gate = next(step for step in steps if step.get("name") == "Require matching acceptance on Cabbage and DigestBox")
assert metadata["with"]["run-id"] == "${{ env.DOCKER_DIGEST_RUN_ID }}"
assert publisher["env"]["DOCKER_DIGEST_RUN_ID"] == "${{ needs.candidate.outputs.digest_run_id }}"
assert metadata["with"]["pattern"] == "digest-*"
assert metadata["with"]["merge-multiple"] is True
assert metadata["with"]["path"] == "${{ runner.temp }}/tested-docker-artifacts"
assert steps.index(metadata) < steps.index(gate)
assert gate["env"]["STAGING_DOCKER_ARTIFACT_DIR"] == "${{ runner.temp }}/tested-docker-artifacts"
docker_workflow = yaml.safe_load(DOCKER_WORKFLOW.read_text())
export = next(step for step in docker_workflow["jobs"]["build"]["steps"] if step.get("name") == "Export digest")
assert export["env"]["ABX_DL_IMAGE_REF"] == "${{ steps.abx_dl_image.outputs.image }}"
assert '"/tmp/digests/abx-dl-image-$ARTIFACT_NAME"' in export["run"]
helper = (REPO_ROOT / "bin" / "staging-acceptance-host.sh").read_text()
assert "abx_dl_image" in helper
source_sha = "a" * 40
base_digest = "sha256:" + "b" * 64
(tmp_path / "source-ref").write_text(source_sha + "\n")
for platform in ("linux-amd64", "linux-arm64"):
(tmp_path / f"abx-dl-image-digest-{platform}").write_text(f"archivebox/abx-dl:1.13.51@{base_digest}\n")
assert _VERIFY_STAGING.tested_abx_dl_digest(tmp_path, source_sha) == base_digest
(tmp_path / "abx-dl-image-digest-linux-arm64").write_text(f"archivebox/abx-dl:1.13.52@{'sha256:' + 'c' * 64}\n")
with pytest.raises(SystemExit, match="different abx-dl images"):
_VERIFY_STAGING.tested_abx_dl_digest(tmp_path, source_sha)
with pytest.raises(SystemExit, match="different source commit"):
_VERIFY_STAGING.tested_abx_dl_digest(tmp_path, "d" * 40)
+65 -1
View File
@@ -1,6 +1,7 @@
import os
import subprocess
from archivebox.services.resource_admission import ResourceAdmission
from archivebox.services.resource_admission import ResourceAdmission, disk_backed_swap_free_bytes
def test_cpu_admission_respects_process_affinity():
@@ -10,6 +11,69 @@ def test_cpu_admission_respects_process_affinity():
assert admission.snapshot_limit(1) == 1
def test_snapshot_admission_stages_first_capture_then_uses_measured_headroom():
admission = ResourceAdmission()
assert admission.additional_snapshot_slots(3, active=0, observed_cost=None) == 1
assert admission.additional_snapshot_slots(3, active=1, observed_cost=None) == 0
# One observed peak costs 500 MB; only measured space is reserved for
# each additional in-flight snapshot, with no fixed minimum RAM.
assert admission.slots_for_headroom(3, active=0, observed_cost=500_000_000, available=1_100_000_000) == 2
assert admission.slots_for_headroom(3, active=1, observed_cost=500_000_000, available=400_000_000) == 0
assert admission.slots_for_headroom(3, active=1, observed_cost=500_000_000, available=900_000_000) == 0
assert admission.slots_for_headroom(3, active=1, observed_cost=500_000_000, available=1_100_000_000) == 1
assert admission.slots_for_headroom(3, active=0, observed_cost=500_000_000, available=0) == 1
# Headroom from memory_headroom() also includes disk-backed free swap.
assert admission.slots_for_headroom(3, active=0, observed_cost=500_000_000, available=2_000_000_000) == 3
assert admission.additional_snapshot_slots(3, active=0, observed_cost=0) == 1
def test_memory_observation_tracks_a_real_child_process():
admission = ResourceAdmission()
before = admission.memory_headroom()
assert before is not None
child = subprocess.Popen(
[
"uv",
"run",
"python",
"-c",
"import sys; allocation = bytearray(64 * 1024 * 1024); allocation[0] = 1; print(len(allocation), flush=True); sys.stdin.read()",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
try:
assert child.stdout is not None
assert child.stdout.readline().strip() == str(64 * 1024 * 1024)
during = admission.memory_headroom()
assert during is not None
assert during[0] > before[0] + 32 * 1024 * 1024
finally:
assert child.stdin is not None
child.stdin.close()
assert child.wait(timeout=10) == 0
def test_disk_backed_swap_headroom_excludes_zram_but_keeps_backing_swap():
swaps = """Filename Type Size Used Priority
/dev/zram0 partition 1000 100 100
/swapfile file 2000 500 -2
/dev/disk-swap partition 4000 1000 -3
"""
assert disk_backed_swap_free_bytes(swaps, {"zram0", "254:0"}) == 4_500 * 1024
def test_disk_backed_swap_headroom_excludes_zram_device_alias():
swaps = """Filename Type Size Used Priority
/dev/block/254:0 partition 800 200 100
/swapfile file 500 100 -2
"""
assert disk_backed_swap_free_bytes(swaps, {"zram0", "254:0"}) == 400 * 1024
def test_memory_stall_counter_recovers_without_resetting_monitor():
# Real kernel PSI units: cumulative microseconds and ten-second average.
# Decision samples exercise the policy, not simulated hooks/processes.
@@ -311,6 +311,43 @@ def test_responses_card_uses_relative_preview_urls_in_static_export(snapshot):
assert "localhost" not in html
@pytest.mark.django_db(transaction=True)
def test_opening_files_from_queued_snapshot_stops_detached_progress_monitor(snapshot, live_server):
from urllib.parse import urlsplit
from playwright.sync_api import sync_playwright
from archivebox.machine.models import Machine
port = urlsplit(live_server.url).port
machine = Machine.current()
machine.config = {**machine.config, "BASE_URL": f"http://archivebox.localhost:{port}"}
machine.save(update_fields=["config"])
snapshot.permissions = "public"
snapshot.status = "queued"
snapshot.save(update_fields=["permissions", "status"])
Path(snapshot.output_dir).mkdir(parents=True, exist_ok=True)
errors = []
progress_requests = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--host-resolver-rules=MAP *.archivebox.localhost 127.0.0.1"])
page = browser.new_page()
page.on("pageerror", lambda error: errors.append(str(error)))
page.on("console", lambda message: errors.append(message.text) if message.type == "error" else None)
page.on("request", lambda request: progress_requests.append(request.url) if "progress.json" in request.url else None)
page.goto(f"http://web.archivebox.localhost:{port}{snapshot.get_absolute_url()}/index.html#all")
page.locator("#main-frame").wait_for()
assert page.locator("#progress-monitor").count() == 0
# Let the real one-second polling interval and any in-flight response
# complete: a detached panel must neither throw nor keep making requests.
page.wait_for_timeout(2200)
count = len(progress_requests)
page.wait_for_timeout(2200)
assert len(progress_requests) == count
assert errors == []
browser.close()
@pytest.mark.django_db(transaction=True)
def test_stack_cover_and_expanded_card_load_same_document(snapshot, live_server):
from urllib.parse import urlsplit
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Exercise the automatically deployed candidate; never deploy it ourselves."""
import json
import hashlib
import os
import re
import subprocess
import sys
import time
import tomllib
from pathlib import Path
from playwright.sync_api import sync_playwright
def main():
target = os.environ["STAGING_TARGET"]
sha = os.environ["STAGING_SHA"]
host, port = {"cabbage": ("100.66.141.33", "44"), "digestbox": ("146.190.134.33", "22")}[target]
evidence = Path("staging-evidence")
evidence.mkdir(exist_ok=True)
ssh = [
"ssh",
"-F",
"/dev/null",
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
f"UserKnownHostsFile={os.environ['STAGING_KNOWN_HOSTS_FILE']}",
"-o",
"ConnectTimeout=15",
"-i",
os.environ["STAGING_KEY_FILE"],
"-p",
port,
f"root@{host}",
]
deadline = time.monotonic() + 90 * 60
inspection = None
expected_pins = {
p["name"]: p["version"]
for p in tomllib.loads(Path("uv.lock").read_text())["package"]
if p["name"] in {"abx-dl", "abx-plugins", "abxbus", "abxpkg"}
}
def remote(operation):
return subprocess.run([*ssh, f"{operation} {sha}"], text=True, capture_output=True, timeout=180)
while time.monotonic() < deadline:
check = remote("inspect")
if check.returncode == 75 and inspection is None:
# Deployment is asynchronous; this is observation, not a test retry
# or permission to pull/restart a container from CI.
print(check.stderr.strip(), flush=True)
time.sleep(30)
continue
if check.returncode:
raise RuntimeError(check.stderr)
current = json.loads(check.stdout)
if current["helper_sha256"] != hashlib.sha256(Path("bin/staging-acceptance-host.sh").read_bytes()).hexdigest():
raise RuntimeError("Staging host acceptance helper differs from the reviewed candidate")
if not isinstance(current.get("abx_dl_image"), str) or not re.search(r"@sha256:[0-9a-f]{64}$", current["abx_dl_image"]):
raise RuntimeError("Staging host did not report a digest-pinned abx-dl base image")
if inspection is not None and current != inspection:
raise RuntimeError("The deployed container changed during acceptance")
inspection = current
result = remote("capture")
(evidence / "capture.log").write_text(result.stdout + result.stderr)
if result.returncode:
raise RuntimeError(result.stderr or result.stdout)
report = json.loads(
next(line.removeprefix("STAGING_REPORT=") for line in result.stdout.splitlines() if line.startswith("STAGING_REPORT=")),
)
(evidence / "capture.json").write_text(json.dumps(report, indent=2))
if report["pins"] != expected_pins:
raise RuntimeError(f"Deployed dependency pins differ: {report['pins']} != {expected_pins}")
print(f"{target}: {report['status']}, crawl {report['crawl_id']}", flush=True)
if report["status"] == "failed":
raise RuntimeError("\n".join(report["errors"]))
if report["status"] == "passed":
break
time.sleep(30)
else:
raise RuntimeError("Automatic deployment/capture did not complete within the acceptance window")
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 1000})
for snapshot in report["snapshots"]:
response = page.goto(snapshot["replay_url"], wait_until="domcontentloaded")
assert response and response.ok, snapshot["replay_url"]
page.locator('.thumb-card[data-plugin-name="screenshot"]').wait_for(state="attached")
page.locator("#main-frame").wait_for(state="attached")
page.screenshot(path=str(evidence / f"{snapshot['id']}.png"), full_page=True)
browser.close()
# Recheck identity and host OOM history after the browser flow too.
final = remote("capture")
if final.returncode:
raise RuntimeError(final.stderr)
final_report = json.loads(
next(line.removeprefix("STAGING_REPORT=") for line in final.stdout.splitlines() if line.startswith("STAGING_REPORT=")),
)
final_inspection = remote("inspect")
if final_report != report or final_inspection.returncode or json.loads(final_inspection.stdout) != inspection:
raise RuntimeError("Deployment or capture evidence changed during the browser acceptance flow")
fingerprint = subprocess.check_output([sys.executable, "bin/release-source-fingerprint.py", sha], text=True).strip()
payload = {"acceptance_version": 1, "source_fingerprint": fingerprint, "run_id": os.environ["GITHUB_RUN_ID"], "inspection": inspection}
request = {
"ref": sha,
"environment": target,
"auto_merge": False,
"required_contexts": [],
"production_environment": False,
"payload": payload,
"description": "Real deployed capture, output and browser acceptance",
}
repository = os.environ["GITHUB_REPOSITORY"]
deployment = json.loads(
subprocess.check_output(["gh", "api", f"repos/{repository}/deployments", "--input", "-"], input=json.dumps(request), text=True),
)
subprocess.run(
["gh", "api", f"repos/{repository}/deployments/{deployment['id']}/statuses", "--input", "-"],
input=json.dumps(
{
"state": "success",
"auto_inactive": False,
"log_url": f"https://github.com/{repository}/actions/runs/{os.environ['GITHUB_RUN_ID']}",
},
),
text=True,
check=True,
)
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Fingerprint a Git tree while ignoring only ArchiveBox's own release version."""
from __future__ import annotations
import hashlib
import re
import subprocess
import sys
VERSION_MARKER = b"<ARCHIVEBOX_PROJECT_VERSION>"
def git(*args: str) -> bytes:
return subprocess.run(
["git", *args],
check=True,
capture_output=True,
).stdout
def normalize_file(path: bytes, content: bytes) -> bytes:
"""Replace exact own-version values while preserving all other file bytes."""
if path == b"pyproject.toml":
lines = content.splitlines(keepends=True)
section = b""
replacements = {b"project": 0, b"tool.bumpver": 0}
normalized = []
for line in lines:
stripped = line.strip()
if stripped.startswith(b"[") and stripped.endswith(b"]"):
section = stripped[1:-1]
key = b"version" if section == b"project" else b"current_version" if section == b"tool.bumpver" else None
if key is not None:
match = re.fullmatch(rb"(\s*" + key + rb"\s*=\s*)\"[^\"\r\n]+(\"\s*(?:#.*)?\r?\n?)", line)
if match:
replacements[section] += 1
line = match.group(1) + b'"' + VERSION_MARKER + match.group(2)
normalized.append(line)
if replacements != {b"project": 1, b"tool.bumpver": 1}:
raise ValueError("pyproject.toml must contain exactly one project.version and tool.bumpver.current_version")
return b"".join(normalized)
if path == b"uv.lock":
blocks = re.split(rb"(?m)(?=^\[\[package\]\]\s*$)", content)
found = 0
for index, block in enumerate(blocks):
if not re.search(rb'(?m)^name = "archivebox"\s*$', block):
continue
updated, count = re.subn(
rb'(?m)^(version = ")[^"\r\n]+("\s*)$',
lambda match: match.group(1) + VERSION_MARKER + match.group(2),
block,
count=1,
)
if count != 1:
raise ValueError("uv.lock ArchiveBox package must have exactly one version")
blocks[index] = updated
found += 1
if found != 1:
raise ValueError("uv.lock must contain exactly one ArchiveBox package")
return b"".join(blocks)
if path == b"etc/package.json":
normalized, count = re.subn(
rb'(?m)^( "version": ")[^"\r\n]+("[,]?\s*)$',
lambda match: match.group(1) + VERSION_MARKER + match.group(2),
content,
count=1,
)
if count != 1:
raise ValueError("etc/package.json must contain exactly one top-level version")
return normalized
return content
def fingerprint(revision: str) -> str:
entries = git("ls-tree", "-rz", "--full-tree", "-r", revision).split(b"\0")
digest = hashlib.sha256()
for entry in entries:
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, object_type, object_id = metadata.split(b" ")
if object_type == b"blob":
content = normalize_file(path, git("cat-file", "blob", object_id.decode("ascii")))
elif mode == b"160000" and object_type == b"commit":
content = object_id
else:
raise ValueError(f"unsupported Git tree entry: {metadata!r}")
for value in (path, mode, object_type, content):
digest.update(len(value).to_bytes(8, "big"))
digest.update(value)
return digest.hexdigest()
if __name__ == "__main__":
print(fingerprint(sys.argv[1] if len(sys.argv) > 1 else "HEAD"))
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Root-owned SSH forced command on our test hosts. Never evaluate the caller's
# input as shell, accept a URL, or permit deployment/container-control commands.
set -Eeuo pipefail
if [[ ! "${SSH_ORIGINAL_COMMAND:-}" =~ ^(inspect|capture)\ ([0-9a-f]{40})$ ]]; then
echo 'Only inspect/capture followed by an exact source SHA is allowed.' >&2
exit 64
fi
operation="${BASH_REMATCH[1]}"
source_sha="${BASH_REMATCH[2]}"
case "$(hostname -s)" in
cabbage) container=archiveboxdemo-archivebox-1 ;;
brussel) container=archivebox-archivebox-1 ;;
*) echo 'Not an ArchiveBox staging host.' >&2; exit 64 ;;
esac
revision="$(docker inspect "$container" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [[ "$revision" != "$source_sha" ]]; then
echo "Awaiting automatic deployment: running $revision, expected $source_sha" >&2
exit 75
fi
test "$(docker inspect "$container" --format '{{.State.Health.Status}}')" = healthy
# A source bind mount would invalidate the immutable image's provenance.
while IFS= read -r destination; do
case "$destination" in
/app|/app/*|/venv|/venv/*|/opt/archivebox|/opt/archivebox/*)
echo "Refusing code mount at $destination" >&2; exit 1 ;;
esac
done < <(docker inspect "$container" --format '{{range .Mounts}}{{println .Destination}}{{end}}')
if [[ "$operation" == inspect ]]; then
helper_hash="$(sha256sum "$0" | cut -d ' ' -f 1)"
docker inspect "$container" --format "{\"helper_sha256\":\"$helper_hash\",\"container_id\":{{json .Id}},\"image_id\":{{json .Image}},\"revision\":{{json (index .Config.Labels \"org.opencontainers.image.revision\")}},\"version\":{{json (index .Config.Labels \"org.opencontainers.image.version\")}},\"abx_dl_image\":{{json (index .Config.Labels \"io.archivebox.abx-dl.image\")}},\"health\":{{json .State.Health.Status}}}"
exit 0
fi
# Persist the start of this candidate's real acceptance flow. Repeated status
# requests observe the same crawl instead of repeatedly adding test data.
install -d -m 700 /var/lib/archivebox-staging
exec 9>/var/lib/archivebox-staging/acceptance.lock
flock -x 9
start_file="/var/lib/archivebox-staging/$source_sha.started"
if [[ ! -e "$start_file" ]]; then date -u +%FT%TZ > "$start_file"; fi
since="$(cat "$start_file")"
# Read separately so a journal failure cannot masquerade as "no OOM found"
# through the conditional pipeline's nonzero exit status.
kernel_log="$(journalctl -k --since "$since" --no-pager)"
if grep 'Out of memory: Killed process' <<< "$kernel_log" >/dev/null; then
echo 'Staging acceptance failed: the host had an OOM kill during this candidate test.' >&2
exit 1
fi
script="$(cat <<'PY'
import json
import os
from pathlib import Path
from archivebox.crawls.models import Crawl
from archivebox.cli.archivebox_add import add
from archivebox.config import VERSION
from archivebox.core.routes_util import build_snapshot_detail_url
from archivebox.plugins.discovery import get_plugin_catalog
from importlib.metadata import version
sha = os.environ['ACCEPTANCE_SHA']
tag = f'release-acceptance-{sha}'
urls = ['https://docs.sweeting.me/s/cookie-dilemma', 'https://sweeting.me/']
capture_plugins = [plugin for plugin in get_plugin_catalog().values() if any(hook.event == 'Snapshot' for hook in plugin.hooks)]
capture_config = {'PERMISSIONS': 'public', **{plugin.enabled_key: True for plugin in capture_plugins}}
crawls = list(Crawl.objects.filter(tags_str=tag).order_by('created_at'))
if not crawls:
# Normal add/runner flow with every snapshot extractor enabled, including
# opt-in TLSNotary. Never select a subset or steal the server runner's lease.
crawl, _ = add(urls=urls, depth=0, tag=tag, bg=True, persona='ReleaseAcceptance', config=capture_config)
crawls = [crawl]
if len(crawls) != 1:
raise RuntimeError('Expected one acceptance crawl for this source')
crawl = crawls[0]
snapshots = list(crawl.snapshot_set.all())
report = {
'source_sha': sha, 'version': VERSION, 'crawl_id': str(crawl.pk),
'pins': {p: version(p) for p in ('abx-dl', 'abx-plugins', 'abxbus', 'abxpkg')},
'status': 'pending', 'snapshots': [], 'errors': [],
'enabled_capture_plugins': sorted(plugin.name for plugin in capture_plugins),
}
complete = crawl.status == 'sealed' and len(snapshots) == len(urls) and all(s.status == 'sealed' for s in snapshots)
for snapshot in snapshots:
results = list(snapshot.archiveresult_set.all())
statuses = {}
for result in results:
statuses.setdefault(result.plugin, []).append(str(result.status))
report['snapshots'].append({'id': str(snapshot.pk), 'url': snapshot.url, 'replay_url': build_snapshot_detail_url(snapshot.archive_path_from_db), 'status': snapshot.status, 'plugins': statuses})
if not complete:
continue
# Real stored outputs, not status labels alone. Missing core capture formats
# and failed extractors remain visible failures for investigation.
for plugin in ('screenshot', 'pdf', 'singlefile', 'archivewebpage', 'readability', 'defuddle', 'hashes', 'tlsnotary'):
if 'succeeded' not in statuses.get(plugin, []):
report['errors'].append(f'{snapshot.pk}: {plugin} did not succeed')
for plugin in capture_plugins:
if plugin.name not in statuses:
report['errors'].append(f'{snapshot.pk}: {plugin.name} never reported an output status')
for result in results:
if result.status == 'failed':
report['errors'].append(f'{snapshot.pk}: {result.plugin}: {result.output_str}')
if result.status != 'succeeded':
continue
for relative, metadata in (result.output_files or {}).items():
if result.plugin == 'chrome':
# Chrome's live session handles are intentionally removed at
# crawl teardown; they are not archived replay artifacts.
continue
base = Path(snapshot.output_dir) if metadata.get('root_relative') else Path(snapshot.output_dir) / result.plugin
output = base / relative
if not output.is_file() or output.stat().st_size != metadata.get('size'):
report['errors'].append(f'{snapshot.pk}: missing or truncated {result.plugin}/{relative}')
if complete:
report['status'] = 'failed' if report['errors'] else 'passed'
print('STAGING_REPORT=' + json.dumps(report, default=str))
PY
)"
docker exec -e "ACCEPTANCE_SHA=$source_sha" "$container" archivebox shell -c "$script"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Fail closed unless both live test servers accepted this exact release source.
CI tests alone do not establish that the automatically deployed image works on
our real servers. In particular, a stable PyPI upload must not overtake a stale
Docker :dev deployment. Only our staging workflow can provide this evidence;
own-version changes are normalized by the shared fingerprint helper, while
dependency pins, application code, and CI changes must still match exactly.
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
def tested_abx_dl_digest(artifact_dir, expected_sha):
artifact_dir = Path(artifact_dir)
source_ref = (artifact_dir / "source-ref").read_text().strip()
if not re.fullmatch(r"[0-9a-f]{40}", expected_sha) or source_ref != expected_sha:
raise SystemExit("Stable publication blocked: tested Docker artifacts belong to a different source commit.")
image_refs = []
for platform in ("linux-amd64", "linux-arm64"):
path = artifact_dir / f"abx-dl-image-digest-{platform}"
image_ref = path.read_text().strip()
match = re.search(r"@(?P<digest>sha256:[0-9a-f]{64})$", image_ref)
if not match:
raise SystemExit(f"Stable publication blocked: {path.name} is not pinned to a sha256 digest.")
image_refs.append(match.group("digest"))
if image_refs[0] != image_refs[1]:
raise SystemExit("Stable publication blocked: amd64 and arm64 Docker builds used different abx-dl images.")
return image_refs[0]
def github(path):
return json.loads(subprocess.check_output(["gh", "api", path], text=True))
def main():
repo = os.environ["GITHUB_REPOSITORY"]
sha = os.environ["RELEASE_SHA"]
fingerprint = subprocess.check_output(
[sys.executable, str(Path(__file__).with_name("release-source-fingerprint.py")), sha],
text=True,
).strip()
expected_abx_dl_digest = tested_abx_dl_digest(os.environ["STAGING_DOCKER_ARTIFACT_DIR"], sha)
for environment in ("cabbage", "digestbox"):
deployments = github(f"repos/{repo}/deployments?environment={environment}&per_page=100")
accepted = False
for deployment in deployments:
payload = deployment.get("payload") or {}
if not isinstance(payload, dict) or payload.get("source_fingerprint") != fingerprint:
continue
if deployment["creator"]["login"] != "github-actions[bot]" or payload.get("acceptance_version") != 1:
continue
statuses = github(f"repos/{repo}/deployments/{deployment['id']}/statuses?per_page=1")
if not statuses or statuses[0]["state"] != "success":
continue
deployed_sha = deployment["sha"]
if not re.fullmatch(r"[0-9a-f]{40}", deployed_sha):
continue
inspection = payload.get("inspection") or {}
if inspection.get("revision") != deployed_sha or inspection.get("health") != "healthy":
continue
if not re.fullmatch(r"sha256:[0-9a-f]{64}", inspection.get("image_id", "")):
continue
abx_dl_image = inspection.get("abx_dl_image", "")
abx_dl_match = re.search(r"@(?P<digest>sha256:[0-9a-f]{64})$", abx_dl_image) if isinstance(abx_dl_image, str) else None
if not abx_dl_match or abx_dl_match.group("digest") != expected_abx_dl_digest:
continue
deployed_fingerprint = subprocess.check_output(
[sys.executable, str(Path(__file__).with_name("release-source-fingerprint.py")), deployed_sha],
text=True,
).strip()
if deployed_fingerprint != fingerprint:
continue
run_id = str(payload.get("run_id", ""))
if not run_id.isdecimal():
continue
run = github(f"repos/{repo}/actions/runs/{run_id}")
if (
run["path"] != ".github/workflows/staging-acceptance.yml"
or run["event"] != "workflow_run"
or run["status"] != "completed"
or run["conclusion"] != "success"
):
continue
print(f"{environment}: accepted {deployment['sha']} with abx-dl {expected_abx_dl_digest} ({run['html_url']})")
accepted = True
break
if not accepted:
raise SystemExit(f"Stable publication blocked: {environment} has not accepted source fingerprint {fingerprint}.")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.65rc5",
"version": "0.9.65rc15",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
+4 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.65rc5"
version = "0.9.65rc15"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@@ -79,8 +79,8 @@ dependencies = [
### Binary/Package Management
"abxbus==2.5.65", # direct imports only; version constrained by abx-dl -> abx-plugins
"abxpkg==1.13.11", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins[opencode]==1.13.74", # optional AI runtime clients are owned by the plugin
"abx-dl==1.13.51", # shared ArchiveBox downloader package
"abx-plugins[opencode]==1.13.77", # optional AI runtime clients are owned by the plugin
"abx-dl==1.13.53", # shared ArchiveBox downloader package
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
"browser-cookie3>=0.20.1",
@@ -343,7 +343,7 @@ Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
[tool.bumpver]
current_version = "v0.9.65rc5"
current_version = "v0.9.65rc15"
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
Generated
+11 -11
View File
@@ -13,18 +13,18 @@ supported-markers = [
]
[options]
exclude-newer = "2026-09-21T19:45:23.438307361Z"
exclude-newer = "2026-09-21T22:18:15.120285209Z"
exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "2026-09-26T19:45:22.438320305Z", span = "PT1S" }
abxbus = { timestamp = "2026-09-26T22:18:14.120299325Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
[[package]]
name = "abx-dl"
version = "1.13.51"
version = "1.13.53"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -38,14 +38,14 @@ dependencies = [
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/11/fb5c84fb991dba472fc2d3616cc6794df45e0b19e54e0e3016720e4d5014/abx_dl-1.13.51.tar.gz", hash = "sha256:33ba928396ea2f87b23d2cb0c1439e39f64c8b3a9a35451608ae2c2b93c21f65", size = 97366 }
sdist = { url = "https://files.pythonhosted.org/packages/5c/f1/1c4669e40dc9f6d3e7e943595a2c2b0d795a27ee1460ae47ea430c7386e8/abx_dl-1.13.53.tar.gz", hash = "sha256:48f0e945e35333add056bfab85fcf51c90596ba226cf8a985dcfa57040818334", size = 97356 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d3/a1d92a76e1a7854d5682a062e2339d91021fc5bb7cae6b99137ad42ab0fb/abx_dl-1.13.51-py3-none-any.whl", hash = "sha256:e57c15e31bfa9a79edcf492ec8aa6b57d94d42a7eb5a5021611758d46eab5838", size = 102834 },
{ url = "https://files.pythonhosted.org/packages/e2/24/787d68c2970ed6ee044e71d0ffbbb68c80983190838e8dc8905bdd1014df/abx_dl-1.13.53-py3-none-any.whl", hash = "sha256:eccd10396a422693e691adf481947a18a522203a8f714644f2597f56d0a6d944", size = 102834 },
]
[[package]]
name = "abx-plugins"
version = "1.13.74"
version = "1.13.77"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -54,9 +54,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/ce/632a9874d19e24b3209e1a202096d5b501ee4e201dc5a70089b6c25fc004/abx_plugins-1.13.74.tar.gz", hash = "sha256:a22613a4e0a67f7deaba4ccb9010c1d0ed085b0ef490597be9646fed778c272a", size = 452403, upload-time = "2026-09-26T18:48:35.101Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/ee/00458913bee25ffc72b86c08355a7dc670c7e4da67ab9a3c9521d13367d8/abx_plugins-1.13.77.tar.gz", hash = "sha256:e1bb3399e076e65348e79889ea48c6e124fb05b4f0e6df5a5f200e29e54fce07", size = 453038, upload-time = "2026-09-26T22:05:36.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/90/bcf6577f5ed1d8f1b72fc59ab240d5223d08d072fabcd37bac3d7d542fc9/abx_plugins-1.13.74-py3-none-any.whl", hash = "sha256:ea133f3d10f83da129b7c4b964f4c1fbd3e7f7e80d84108f741938c6ab548003", size = 661641, upload-time = "2026-09-26T18:48:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/c7fc8a8437d02d336888ed7b9fe41c04108b0a59022ef8bbc9d0ac8cc7f5/abx_plugins-1.13.77-py3-none-any.whl", hash = "sha256:c9d74aab61e09e79766f79cb8ce9b4844d74af7f74d3a4d37b7ba2fdcb67fe50", size = 662367, upload-time = "2026-09-26T22:05:37.751Z" },
]
[package.optional-dependencies]
@@ -127,7 +127,7 @@ wheels = [
[[package]]
name = "archivebox"
version = "0.9.65rc5"
version = "0.9.65rc15"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -227,8 +227,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.13.51" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.13.74" },
{ name = "abx-dl", specifier = "==1.13.53" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.13.77" },
{ name = "abxbus", specifier = "==2.5.65" },
{ name = "abxpkg", specifier = "==1.13.11" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },