Skip to content

v1.5.8のリリース(品質改善アップデート) #707

v1.5.8のリリース(品質改善アップデート)

v1.5.8のリリース(品質改善アップデート) #707

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_COLOR: 1
TOX_TESTENV_PASSENV: FORCE_COLOR
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
# ============================================
# Should-Run Check - Determines if CI is needed
# main push / workflow_dispatch: 常に実行
# PR: src/ tests/ pyproject.toml tox.ini の変更時のみ実行
# ============================================
should-run:
name: Check if CI needed
runs-on: ubuntu-latest
outputs:
run_ci: ${{ steps.check.outputs.run_ci }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check changed files
id: check
run: |
# workflow_dispatch: always run
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "run_ci=true" >> $GITHUB_OUTPUT
echo "Manual trigger - CI required"
exit 0
fi
# For main branch push: always run
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "run_ci=true" >> $GITHUB_OUTPUT
echo "Main branch - CI required"
exit 0
fi
# For PRs: check changed files
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.sha }}"
CHANGED=$(git diff --name-only "$BASE" "$HEAD")
echo "Changed files:"
echo "$CHANGED"
if echo "$CHANGED" | grep -qE '^(src|tests)/|^pyproject\.toml$|^tox\.ini$'; then
echo "run_ci=true" >> $GITHUB_OUTPUT
echo "→ src/tests changed: CI required"
else
echo "run_ci=false" >> $GITHUB_OUTPUT
echo "→ no src/tests changes: CI skipped"
fi
# ============================================
# Lint Job - All branches
# ============================================
lint:
name: Lint (Ruff)
needs: should-run
if: needs.should-run.outputs.run_ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install tox
run: pip install tox
- name: Run python -m tox -e lint
run: python -m tox -e lint
# ============================================
# Type Check Job - All branches
# ============================================
type:
name: Type Check (mypy)
needs: should-run
if: needs.should-run.outputs.run_ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install tox
run: pip install tox
- name: Run python -m tox -e type
run: python -m tox -e type
# ============================================
# Test Job - Matrix based on branch
# main: Python 3.9-3.14 × all OS
# other: Python 3.13 × each OS (limited)
# ============================================
test:
name: Test (py${{ matrix.python-version }}, ${{ matrix.os }})
needs: should-run
if: needs.should-run.outputs.run_ci == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ${{ github.ref == 'refs/heads/main' && fromJSON('["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]') || fromJSON('["3.13"]') }}
exclude:
# macOS: limit to 2 popular versions (3.11, 3.13)
- os: macos-latest
python-version: "3.9"
- os: macos-latest
python-version: "3.10"
- os: macos-latest
python-version: "3.12"
- os: macos-latest
python-version: "3.14"
# Windows: limit to 2 popular versions (3.11, 3.13)
- os: windows-latest
python-version: "3.9"
- os: windows-latest
python-version: "3.10"
- os: windows-latest
python-version: "3.12"
- os: windows-latest
python-version: "3.14"
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
# Windows: TEMP optimization
- name: Setup Windows temp directory
if: runner.os == 'Windows'
shell: powershell
run: New-Item -ItemType Directory -Force -Path D:\\temp
- name: Install dependencies
run: |
uv pip install --system pytest pytest-asyncio pytest-xdist pytest-cov apsw pydantic cryptography orjson lru-dict
uv pip install --system -e ".[dev]"
# Windows: TEMP optimization + fixed parallelism
- name: Run tests (Windows)
if: runner.os == 'Windows'
env:
TEMP: 'D:\\temp'
TMP: 'D:\\temp'
run: |
python -m pytest tests/ -v --tb=short --junitxml=test-results.xml --cov=nanasqlite --cov-report=xml:coverage.xml -n 4
# Linux/macOS: auto parallelism
- name: Run tests (Linux/macOS)
if: runner.os != 'Windows'
run: |
python -m pytest tests/ -v --tb=short --junitxml=test-results.xml --cov=nanasqlite --cov-report=xml:coverage.xml -n auto
- name: Upload coverage to Artifacts
uses: actions/upload-artifact@v7
with:
name: coverage-report-${{ matrix.os }}-py${{ matrix.python-version }}
path: coverage.xml
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v7
if: always()
with:
name: test-results-${{ matrix.os }}-py${{ matrix.python-version }}
path: test-results.xml
retention-days: 7
# ============================================
# All-Green Summary - Always runs
# skipped は正常扱い(src/tests 未変更の場合)
# ============================================
summary:
name: ✅ All Checks
needs: [should-run, lint, type, test]
runs-on: ubuntu-latest
timeout-minutes: 3
if: always()
steps:
- name: Check job results
run: |
echo ""
echo "📝 Job statuses:"
echo "📝 should-run → run_ci=${{ needs.should-run.outputs.run_ci }}"
echo "📝 lint → ${{ needs.lint.result == 'success' && '✓ success' || needs.lint.result == 'skipped' && '⏭ skipped' || format('✗ {0}', needs.lint.result) }} [required]"
echo "📝 type → ${{ needs.type.result == 'success' && '✓ success' || needs.type.result == 'skipped' && '⏭ skipped' || format('✗ {0}', needs.type.result) }} [required]"
echo "📝 test → ${{ needs.test.result == 'success' && '✓ success' || needs.test.result == 'skipped' && '⏭ skipped' || format('✗ {0}', needs.test.result) }} [required]"
echo ""
# skipped は正常扱い(src/tests 未変更によるスキップ)
FAILED=false
for result in \
"${{ needs.lint.result }}" \
"${{ needs.type.result }}" \
"${{ needs.test.result }}"; do
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
FAILED=true
fi
done
if [ "$FAILED" = true ]; then
echo "❌ One or more CI checks failed."
exit 1
fi
if [ "${{ needs.should-run.outputs.run_ci }}" == "false" ]; then
echo "# ⏭️ CI skipped (no src/tests changes) ✓"
else
echo "# ✅ All CI checks passed 🎉🎉🎉"
fi
- name: Generate Summary
if: always()
run: |
echo "## 🔍 CI Pipeline Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Stage | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
case "${{ needs.lint.result }}" in
success) echo "| 🧹 Lint (ruff) | ✅ Passed |" >> $GITHUB_STEP_SUMMARY ;;
cancelled) echo "| 🧹 Lint (ruff) | ⚠️ Cancelled |" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "| 🧹 Lint (ruff) | ⏭️ Skipped |" >> $GITHUB_STEP_SUMMARY ;;
*) echo "| 🧹 Lint (ruff) | ❌ Failed |" >> $GITHUB_STEP_SUMMARY ;;
esac
case "${{ needs.type.result }}" in
success) echo "| 🔍 Type (mypy) | ✅ Passed |" >> $GITHUB_STEP_SUMMARY ;;
cancelled) echo "| 🔍 Type (mypy) | ⚠️ Cancelled |" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "| 🔍 Type (mypy) | ⏭️ Skipped |" >> $GITHUB_STEP_SUMMARY ;;
*) echo "| 🔍 Type (mypy) | ❌ Failed |" >> $GITHUB_STEP_SUMMARY ;;
esac
case "${{ needs.test.result }}" in
success) echo "| 🧪 Tests | ✅ Passed |" >> $GITHUB_STEP_SUMMARY ;;
cancelled) echo "| 🧪 Tests | ⚠️ Cancelled |" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "| 🧪 Tests | ⏭️ Skipped |" >> $GITHUB_STEP_SUMMARY ;;
*) echo "| 🧪 Tests | ❌ Failed |" >> $GITHUB_STEP_SUMMARY ;;
esac
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "🚀 **Main branch** - Ready for release if all checks pass" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📋 Local Commands" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "python -m tox -e lint # Ruff linter" >> $GITHUB_STEP_SUMMARY
echo "python -m tox -e type # mypy type check" >> $GITHUB_STEP_SUMMARY
echo "pytest tests/ # Run tests" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
# ============================================
# Check Version - Main branch only
# ============================================
check-version:
name: Check Version
needs: [summary]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && needs.summary.result == 'success'
outputs:
should_publish: ${{ steps.check.outputs.should_publish }}
local_version: ${{ steps.check.outputs.local_version }}
pypi_version: ${{ steps.check.outputs.pypi_version }}
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Check version difference
id: check
run: |
LOCAL_VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/nanasqlite/__init__.py || echo "0.0.0")
echo "local_version=$LOCAL_VERSION" >> $GITHUB_OUTPUT
echo "Local version: $LOCAL_VERSION"
if [[ "$LOCAL_VERSION" =~ (a|alpha|b|beta|rc|dev) ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "⚠️ Prerelease version detected"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
if curl -s "https://pypi.org/pypi/nanasqlite/${LOCAL_VERSION}/json" 2>/dev/null | grep -q '"version"'; then
PYPI_VERSION="$LOCAL_VERSION"
echo "PyPI version: $PYPI_VERSION (exact match found)"
echo "should_publish=false" >> $GITHUB_OUTPUT
echo "✅ Version $LOCAL_VERSION already on PyPI, skipping publish"
else
PYPI_VERSION=$(curl -s https://pypi.org/pypi/nanasqlite/json 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' | head -1 || echo "0.0.0")
if [ -z "$PYPI_VERSION" ]; then
PYPI_VERSION="0.0.0"
fi
echo "PyPI version: $PYPI_VERSION (latest)"
echo "should_publish=true" >> $GITHUB_OUTPUT
echo "📦 New version detected: $LOCAL_VERSION"
fi
echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT
# ============================================
# Build - Main branch only, new version only
# ============================================
build:
name: Build Package
needs: [check-version]
if: needs.check-version.outputs.should_publish == 'true'
runs-on: ubuntu-latest
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install build tools
run: uv pip install --system build
- name: Build package
run: python -m build
- name: Generate hashes
id: hash
shell: bash
run: |
cd dist && echo "hashes=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
retention-days: 7
# ============================================
# SLSA Provenance - Main branch only, new version only
# ============================================
# NOTE: The "provenance / generator" job produces an expected warning:
# "Restore cache failed: Dependencies file is not found ... Supported file pattern: go.sum"
# This comes from the SLSA generator's internal setup-go step, which tries to
# restore a Go module cache using go.sum as the cache key. Since NanaSQLite is
# a pure-Python project, go.sum does not exist and the cache cannot be restored.
# This warning is harmless — SLSA3 provenance is still generated correctly.
# It is an internal limitation of the slsa-github-generator reusable workflow
# and cannot be suppressed from the calling workflow. Tracked upstream:
# https://github.com/slsa-framework/slsa-github-generator/pull/3604
provenance:
needs: [build]
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: "${{ needs.build.outputs.hashes }}"
# upload-assets is intentionally omitted: this workflow has no tag-based trigger,
# so the SLSA generator would always skip it. The provenance artifact is instead
# downloaded and attached to the GitHub Release in the release job below.
# ============================================
# Verify SLSA Provenance - Automated check before release
# ============================================
verify-provenance:
name: Verify SLSA Provenance
needs: [check-version, build, provenance]
if: needs.check-version.outputs.should_publish == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist/
- name: Download provenance
uses: actions/download-artifact@v8.0.1
with:
name: ${{ needs.provenance.outputs.provenance-name }}
path: provenance-dir/
- name: Install slsa-verifier
uses: slsa-framework/slsa-verifier/actions/installer@v2.7.1
- name: Verify provenance
run: |
PROVENANCE_FILE="provenance-dir/${{ needs.provenance.outputs.provenance-name }}"
if [ ! -f "$PROVENANCE_FILE" ]; then
echo "Expected provenance file not found: $PROVENANCE_FILE"
exit 1
fi
echo "Verifying artifacts against $PROVENANCE_FILE"
for file in dist/*; do
echo "Verifying $file..."
slsa-verifier verify-artifact "$file" \
--provenance-path "$PROVENANCE_FILE" \
--source-uri "github.com/${{ github.repository }}" \
--source-branch "main"
done
# ============================================
# Publish - Main branch only, new version only
# ============================================
publish:
name: Publish to PyPI
needs: [check-version, build, verify-provenance]
if: needs.check-version.outputs.should_publish == 'true'
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # Required for PyPI Trusted Publishing.
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist/
# NOTE: The "Generating and uploading digital attestations" notice is expected.
# pypa/gh-action-pypi-publish@release/v1 automatically generates GitHub Artifact
# Attestations (PyPI publish/v1 predicate) when id-token: write is present.
# These are distinct from, and complementary to, the SLSA3 provenance generated
# above. PyPI Trusted Publishing requires these attestations. This is correct
# and desirable behaviour — no action is needed.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.14.0
# ============================================
# Release - Main branch only, after publish
# ============================================
release:
name: GitHub Release
needs: [check-version, build, publish, provenance, verify-provenance]
if: needs.check-version.outputs.should_publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist/
- name: Download provenance
uses: actions/download-artifact@v8.0.1
with:
name: ${{ needs.provenance.outputs.provenance-name }}
path: provenance-dir/
- name: Create source zip
run: |
zip -r nanasqlite-${{ needs.check-version.outputs.local_version }}-src.zip src/ README.md LICENSE pyproject.toml
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.check-version.outputs.local_version }}
name: v${{ needs.check-version.outputs.local_version }}
body: |
## NanaSQLite v${{ needs.check-version.outputs.local_version }}
### インストール / Installation
```bash
pip install nanasqlite==${{ needs.check-version.outputs.local_version }}
```
### ダウンロード / Downloads
- 📦 **wheel**: ビルド済みパッケージ
- 📁 **tar.gz**: ソース配布物
- 🗂️ **src.zip**: ソースコードのみ
詳細は [CHANGELOG.md](CHANGELOG.md) を参照してください。
files: |
dist/*
nanasqlite-${{ needs.check-version.outputs.local_version }}-src.zip
provenance-dir/${{ needs.provenance.outputs.provenance-name }}
draft: false
prerelease: ${{ needs.check-version.outputs.is_prerelease == 'true' }}
- name: Release Summary
run: |
echo "## 🎉 Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** v${{ needs.check-version.outputs.local_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Published to:" >> $GITHUB_STEP_SUMMARY
echo "- ✅ [PyPI](https://pypi.org/project/nanasqlite/${{ needs.check-version.outputs.local_version }}/)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ [GitHub Release](https://github.com/${{ github.repository }}/releases/tag/v${{ needs.check-version.outputs.local_version }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "pip install nanasqlite==${{ needs.check-version.outputs.local_version }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
# ============================================
# PR Comment - PR only, always runs
# 既存コメントがあれば上書き、なければ新規投稿
# ============================================
pr-comment:
name: Post PR Comment
needs: [should-run, lint, type, test]
runs-on: ubuntu-latest
timeout-minutes: 3
if: always() && github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Post CI results
uses: actions/github-script@v9
with:
script: |
const lint = '${{ needs.lint.result }}';
const type = '${{ needs.type.result }}';
const test = '${{ needs.test.result }}';
const runCi = '${{ needs.should-run.outputs.run_ci }}';
const icon = (r) => ({
success: '✅',
skipped: '⏭️',
cancelled: '⚠️',
}[r] ?? '❌');
const allOk = [lint, type, test].every(r => ['success', 'skipped'].includes(r));
const header = runCi === 'false'
? '⏭️ CI skipped (no src/tests changes)'
: allOk
? '✅ All CI checks passed'
: '❌ One or more CI checks failed';
const body = [
`## 🔍 CI Results — ${header}`,
'',
'| Stage | Status |',
'|-------|--------|',
`| 🧹 Lint (ruff) | ${icon(lint)} \`${lint}\` |`,
`| 🔍 Type (mypy) | ${icon(type)} \`${type}\` |`,
`| 🧪 Tests | ${icon(test)} \`${test}\` |`,
'',
`**Commit:** \`${{ github.sha }}\``,
'<!-- ci-results-marker -->',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes('<!-- ci-results-marker -->'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}