Files
ArchiveBox/bin/release.sh
Nick Sweeting 1d94645abd test fixes
2026-03-23 04:12:31 -07:00

402 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKSPACE_DIR="$(cd "${REPO_DIR}/.." && pwd)"
cd "${REPO_DIR}"
TAG_PREFIX="v"
PYPI_PACKAGE="archivebox"
source_optional_env() {
if [[ -f "${REPO_DIR}/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "${REPO_DIR}/.env"
set +a
fi
}
repo_slug() {
python3 - <<'PY'
import re
import subprocess
remote = subprocess.check_output(
['git', 'remote', 'get-url', 'origin'],
text=True,
).strip()
patterns = [
r'github\.com[:/](?P<slug>[^/]+/[^/.]+)(?:\.git)?$',
r'github\.com/(?P<slug>[^/]+/[^/.]+)(?:\.git)?$',
]
for pattern in patterns:
match = re.search(pattern, remote)
if match:
print(match.group('slug'))
raise SystemExit(0)
raise SystemExit(f'Unable to parse GitHub repo slug from remote: {remote}')
PY
}
default_branch() {
if [[ -n "${DEFAULT_BRANCH:-}" ]]; then
echo "${DEFAULT_BRANCH}"
return 0
fi
if git symbolic-ref refs/remotes/origin/HEAD >/dev/null 2>&1; then
git symbolic-ref refs/remotes/origin/HEAD | sed 's#^refs/remotes/origin/##'
return 0
fi
git remote show origin | sed -n '/HEAD branch/s/.*: //p' | head -n 1
}
current_version() {
python3 - <<'PY'
from pathlib import Path
import json
import re
versions = []
pyproject_text = Path('pyproject.toml').read_text()
pyproject_match = re.search(r'^version = "([^"]+)"$', pyproject_text, re.MULTILINE)
if pyproject_match:
versions.append(pyproject_match.group(1))
package_json = json.loads(Path('etc/package.json').read_text())
if 'version' in package_json:
versions.append(package_json['version'])
def parse(version: str) -> tuple[int, int, int, int, int]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', version)
if not match:
raise SystemExit(f'Unsupported version format: {version}')
major, minor, patch, rc = match.groups()
rc_value = int(rc) if rc else (0 if 'rc' in version else 10_000)
return (int(major), int(minor), int(patch), 0 if 'rc' in version else 1, rc_value)
print(max(versions, key=parse))
PY
}
bump_version() {
python3 - <<'PY'
from pathlib import Path
import json
import re
def parse(version: str) -> tuple[int, int, int, int, int]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', version)
if not match:
raise SystemExit(f'Unsupported version format: {version}')
major, minor, patch, rc = match.groups()
rc_value = int(rc) if rc else (0 if 'rc' in version else 10_000)
return (int(major), int(minor), int(patch), 0 if 'rc' in version else 1, rc_value)
pyproject_path = Path('pyproject.toml')
pyproject_text = pyproject_path.read_text()
pyproject_match = re.search(r'^version = "([^"]+)"$', pyproject_text, re.MULTILINE)
if not pyproject_match:
raise SystemExit('Failed to find version in pyproject.toml')
package_path = Path('etc/package.json')
package_json = json.loads(package_path.read_text())
if 'version' not in package_json:
raise SystemExit('Failed to find version in etc/package.json')
current_version = max([pyproject_match.group(1), package_json['version']], key=parse)
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', current_version)
major, minor, patch, rc = match.groups()
if 'rc' in current_version:
rc_number = int(rc or '0') + 1
next_version = f'{major}.{minor}.{patch}rc{rc_number}'
else:
next_version = f'{major}.{minor}.{int(patch) + 1}'
pyproject_path.write_text(
re.sub(r'^version = "[^"]+"$', f'version = "{next_version}"', pyproject_text, count=1, flags=re.MULTILINE)
)
package_json['version'] = next_version
package_path.write_text(json.dumps(package_json, indent=2) + '\n')
print(next_version)
PY
}
read_repo_version() {
local repo_dir="$1"
if [[ ! -f "${repo_dir}/pyproject.toml" ]]; then
return 1
fi
python3 - "${repo_dir}/pyproject.toml" <<'PY'
from pathlib import Path
import re
import sys
text = Path(sys.argv[1]).read_text()
match = re.search(r'^version = "([^"]+)"$', text, re.MULTILINE)
if not match:
raise SystemExit('Failed to find version')
print(match.group(1))
PY
}
update_internal_dependencies() {
local abxbus_version abx_pkg_version abx_plugins_version abx_dl_version
abxbus_version="$(read_repo_version "${WORKSPACE_DIR}/abxbus" || true)"
abx_pkg_version="$(read_repo_version "${WORKSPACE_DIR}/abx-pkg" || true)"
abx_plugins_version="$(read_repo_version "${WORKSPACE_DIR}/abx-plugins" || true)"
abx_dl_version="$(read_repo_version "${WORKSPACE_DIR}/abx-dl" || true)"
python3 - "${abxbus_version}" "${abx_pkg_version}" "${abx_plugins_version}" "${abx_dl_version}" <<'PY'
from pathlib import Path
import re
import sys
path = Path('pyproject.toml')
text = path.read_text()
for name, version in (
('abxbus', sys.argv[1]),
('abx-pkg', sys.argv[2]),
('abx-plugins', sys.argv[3]),
('abx-dl', sys.argv[4]),
):
if version:
text = re.sub(rf'("{re.escape(name)}>=)[^"]+(")', rf'\g<1>{version}\2', text)
path.write_text(text)
PY
}
compare_versions() {
python3 - "$1" "$2" <<'PY'
import re
import sys
def parse(version: str) -> tuple[int, int, int, int, int]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', version)
if not match:
raise SystemExit(f'Unsupported version format: {version}')
major, minor, patch, rc = match.groups()
return (int(major), int(minor), int(patch), 0 if 'rc' in version else 1, int(rc or '0'))
left, right = sys.argv[1], sys.argv[2]
if parse(left) > parse(right):
print('gt')
elif parse(left) == parse(right):
print('eq')
else:
print('lt')
PY
}
latest_release_version() {
local slug="$1"
local raw_tags
raw_tags="$(gh api "repos/${slug}/releases?per_page=100" --jq '.[].tag_name' || true)"
RELEASE_TAGS="${raw_tags}" TAG_PREFIX_VALUE="${TAG_PREFIX}" python3 - <<'PY'
import os
import re
def parse(version: str) -> tuple[int, int, int, int, int]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-?rc(\d*))?$', version)
if not match:
return (-1, -1, -1, -1, -1)
major, minor, patch, rc = match.groups()
return (int(major), int(minor), int(patch), 0 if 'rc' in version else 1, int(rc or '0'))
prefix = os.environ.get('TAG_PREFIX_VALUE', '')
versions = [line.strip() for line in os.environ.get('RELEASE_TAGS', '').splitlines() if line.strip()]
if prefix:
versions = [version[len(prefix):] if version.startswith(prefix) else version for version in versions]
if not versions:
print('')
else:
print(max(versions, key=parse))
PY
}
wait_for_runs() {
local slug="$1"
local event="$2"
local sha="$3"
local label="$4"
local runs_json
local attempts=0
while :; do
runs_json="$(GH_FORCE_TTY=0 GH_PAGER=cat gh run list --repo "${slug}" --event "${event}" --commit "${sha}" --limit 20 --json databaseId,status,conclusion,workflowName)"
if [[ "$(jq 'length' <<<"${runs_json}")" -gt 0 ]]; then
break
fi
attempts=$((attempts + 1))
if [[ "${attempts}" -ge 30 ]]; then
echo "Timed out waiting for ${label} workflows to start" >&2
return 1
fi
sleep 10
done
while read -r run_id; do
gh run watch "${run_id}" --repo "${slug}" --exit-status
done < <(jq -r '.[].databaseId' <<<"${runs_json}")
}
wait_for_pypi() {
local package_name="$1"
local expected_version="$2"
local attempts=0
local published_version
while :; do
published_version="$(curl -fsSL "https://pypi.org/pypi/${package_name}/json" | jq -r '.info.version')"
if [[ "${published_version}" == "${expected_version}" ]]; then
return 0
fi
attempts=$((attempts + 1))
if [[ "${attempts}" -ge 30 ]]; then
echo "Timed out waiting for ${package_name}==${expected_version} on PyPI" >&2
return 1
fi
sleep 10
done
}
run_checks() {
uv sync --all-extras --all-groups --no-cache --upgrade
uv build --all
}
validate_release_state() {
local slug="$1"
local branch="$2"
local current latest relation
if [[ "$(git branch --show-current)" != "${branch}" ]]; then
echo "Skipping release-state validation on non-default branch $(git branch --show-current)"
return 0
fi
current="$(current_version)"
latest="$(latest_release_version "${slug}")"
if [[ -z "${latest}" ]]; then
echo "No published releases found for ${slug}; release state is valid"
return 0
fi
relation="$(compare_versions "${current}" "${latest}")"
if [[ "${relation}" == "lt" ]]; then
echo "Current version ${current} is behind latest published version ${latest}" >&2
return 1
fi
echo "Release state is valid: local=${current} latest=${latest}"
}
create_release() {
local slug="$1"
local version="$2"
local prerelease_args=()
if [[ "${version}" == *rc* ]]; then
prerelease_args+=(--prerelease)
fi
if gh release view "${TAG_PREFIX}${version}" --repo "${slug}" >/dev/null 2>&1; then
echo "GitHub release ${TAG_PREFIX}${version} already exists"
return 0
fi
gh release create "${TAG_PREFIX}${version}" \
--repo "${slug}" \
--target "$(git rev-parse HEAD)" \
--title "${TAG_PREFIX}${version}" \
--generate-notes \
"${prerelease_args[@]}"
}
publish_artifacts() {
local version="$1"
local pypi_token="${UV_PUBLISH_TOKEN:-${PYPI_TOKEN:-${PYPI_PAT_SECRET:-}}}"
if curl -fsSL "https://pypi.org/pypi/${PYPI_PACKAGE}/json" | jq -e --arg version "${version}" '.releases[$version] | length > 0' >/dev/null 2>&1; then
echo "${PYPI_PACKAGE} ${version} already published on PyPI"
else
if [[ -n "${pypi_token}" ]]; then
UV_PUBLISH_TOKEN="${pypi_token}" uv publish --username=__token__ dist/*
elif [[ -n "${GITHUB_ACTIONS:-}" ]]; then
uv publish --trusted-publishing always dist/*
else
echo "Missing PyPI credentials: set UV_PUBLISH_TOKEN or PYPI_TOKEN" >&2
return 1
fi
fi
wait_for_pypi "${PYPI_PACKAGE}" "${version}"
}
main() {
local slug branch version latest relation
source_optional_env
slug="$(repo_slug)"
branch="$(default_branch)"
if [[ "${GITHUB_EVENT_NAME:-}" == "push" ]]; then
validate_release_state "${slug}" "${branch}"
return 0
fi
if [[ "$(git branch --show-current)" != "${branch}" ]]; then
echo "Release must run from ${branch}, found $(git branch --show-current)" >&2
return 1
fi
version="$(current_version)"
latest="$(latest_release_version "${slug}")"
if [[ -z "${latest}" ]]; then
relation="gt"
else
relation="$(compare_versions "${version}" "${latest}")"
fi
if [[ "${relation}" == "eq" ]]; then
update_internal_dependencies
version="$(bump_version)"
run_checks
git add -A
git commit -m "release: ${TAG_PREFIX}${version}"
git push origin "${branch}"
wait_for_runs "${slug}" push "$(git rev-parse HEAD)" "push"
elif [[ "${relation}" == "gt" ]]; then
if [[ -n "$(git status --short)" ]]; then
echo "Refusing to publish existing unreleased version ${version} with a dirty worktree" >&2
return 1
fi
run_checks
wait_for_runs "${slug}" push "$(git rev-parse HEAD)" "push"
else
echo "Current version ${version} is behind latest GitHub release ${latest}" >&2
return 1
fi
publish_artifacts "${version}"
create_release "${slug}" "${version}"
latest="$(latest_release_version "${slug}")"
relation="$(compare_versions "${latest}" "${version}")"
if [[ "${relation}" != "eq" ]]; then
echo "GitHub release version mismatch: expected ${version}, got ${latest}" >&2
return 1
fi
echo "Released ${PYPI_PACKAGE} ${version}"
}
main "$@"