diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index 19d117e..23f0df6 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -27,6 +27,7 @@ jobs: outputs: app: ${{ steps.filter.outputs.app }} ci: ${{ steps.filter.outputs.ci }} + packaging: ${{ steps.filter.outputs.packaging }} steps: - uses: dorny/paths-filter@v3 id: filter @@ -60,6 +61,10 @@ jobs: - 'docs/bootstrap/**' - '.env.example' - 'CODEOWNERS' + packaging: + - 'pyproject.toml' + - '.github/workflows/release.yml' + - 'scripts/release/**' fast-checks: name: Fast Checks @@ -89,6 +94,29 @@ jobs: - name: Scan repository for secret patterns run: bash scripts/check-detect-secrets.sh --all-files + package-build: + name: Package Build + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: changes + if: >- + github.event.pull_request.draft == false && + needs.changes.outputs.packaging == 'true' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install build backend + run: python -m pip install --upgrade pip build + + - name: Build distribution artifacts + run: bash scripts/release/dry-run.sh --skip-fast-checks + ci-gate: name: CI Gate runs-on: ['self-hosted', 'synology', 'shell-only', 'private'] @@ -97,6 +125,7 @@ jobs: - changes - fast-checks - validate-secrets + - package-build steps: - name: Check required PR jobs env: @@ -104,6 +133,7 @@ jobs: changes=${{ needs.changes.result }} fast-checks=${{ needs.fast-checks.result }} validate-secrets=${{ needs.validate-secrets.result }} + package-build=${{ needs.package-build.result }} run: | failed=0 for entry in $RESULTS; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4e4e0a8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +defaults: + run: + shell: bash + +jobs: + github-release: + name: Build GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build backend + run: python -m pip install --upgrade pip build + + - name: Validate tag and build artifacts + env: + RELEASE_TAG: ${{ github.ref_name }} + run: bash scripts/release/dry-run.sh + + - name: Extract changelog body + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + python - <<'PY' > release-notes.md + from pathlib import Path + import os + import re + import sys + + tag = os.environ["RELEASE_TAG"] + version = tag.removeprefix("v") + changelog = Path("CHANGELOG.md") + if not changelog.exists(): + raise SystemExit("CHANGELOG.md is required for release notes") + + text = changelog.read_text() + pattern = rf"^## \[{re.escape(version)}\][^\n]*\n(?P.*?)(?=^## \[|\Z)" + match = re.search(pattern, text, flags=re.MULTILINE | re.DOTALL) + if not match: + raise SystemExit(f"CHANGELOG.md has no section for {version}") + + body = match.group("body").strip() + if not body: + raise SystemExit(f"CHANGELOG.md section for {version} is empty") + sys.stdout.write(body + "\n") + PY + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release create "$RELEASE_TAG" \ + --title "$RELEASE_TAG" \ + --notes-file release-notes.md \ + dist/*.tar.gz dist/*.whl dist/SHA256SUMS diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 73c6d97..eda1768 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -21,8 +21,10 @@ repo: - .github/workflows/pr-fast-ci.yml - .github/workflows/extended-validation.yml - .github/workflows/claude.yml + - .github/workflows/release.yml - scripts/check-detect-secrets.sh - scripts/ci/** + - scripts/release/** - scripts/codex-cloud/** - scripts/claude-cloud/** - scripts/claude/** @@ -109,8 +111,8 @@ capabilities: provider: cloudflare-pages outputDir: dist release: - enabled: false - kind: none + enabled: true + kind: github-release docsPublish: enabled: false containers: diff --git a/scripts/release/dry-run.sh b/scripts/release/dry-run.sh new file mode 100755 index 0000000..8c520d1 --- /dev/null +++ b/scripts/release/dry-run.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_FAST_CHECKS=1 +for arg in "$@"; do + case "$arg" in + --skip-fast-checks) + RUN_FAST_CHECKS=0 + ;; + *) + echo "usage: $0 [--skip-fast-checks]" >&2 + exit 2 + ;; + esac +done + +if [[ -z "${PYTHON_BIN:-}" ]]; then + if command -v python3.12 >/dev/null 2>&1; then + PYTHON_BIN=python3.12 + else + PYTHON_BIN=python3 + fi +fi + +PACKAGE_VERSION="$("$PYTHON_BIN" - <<'PY' +from pathlib import Path +import tomllib + +project = tomllib.loads(Path("pyproject.toml").read_text())["project"] +print(project["version"]) +PY +)" +RELEASE_TAG="${RELEASE_TAG:-v$PACKAGE_VERSION}" + +if [[ "$RELEASE_TAG" != "v$PACKAGE_VERSION" ]]; then + echo "release tag $RELEASE_TAG does not match pyproject.toml version $PACKAGE_VERSION" >&2 + exit 1 +fi + +if [[ "$RUN_FAST_CHECKS" == "1" ]]; then + bash scripts/ci/run-fast-checks.sh +fi + +"$PYTHON_BIN" - <<'PY' +import importlib.util +import sys + +if importlib.util.find_spec("build") is None: + raise SystemExit("python -m build is unavailable; install it with: python -m pip install build") +PY + +rm -rf dist +"$PYTHON_BIN" -m build + +( + cd dist + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 > SHA256SUMS +) + +echo "Built release artifacts for $RELEASE_TAG:" +ls -1 dist