Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Python CI (reusable)

# Validace, lint, testy a build Python knihovny.
# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-ci.yml@v1

on:
workflow_call:
inputs:
python-versions:
description: 'JSON pole verzí Pythonu pro testy'
type: string
default: '["3.11", "3.12", "3.13"]'
build-python-version:
description: 'Verze Pythonu pro lint a build'
type: string
default: '3.12'
runs-on:
description: 'Runner pro CI (výchozí self-hosted; publikace běží vždy na GitHub-hosted)'
type: string
default: 'self-hosted'
install-command:
description: 'Instalace projektu s dev závislostmi'
type: string
default: 'pip install -e ".[dev]"'
lint-command:
description: 'Lint (prázdné = přeskočit)'
type: string
default: 'ruff check .'
test-command:
description: 'Spuštění testů'
type: string
default: 'python -m pytest'

permissions: {}

jobs:
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ${{ inputs.runs-on }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(inputs.python-versions) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Create isolated virtualenv
# Nikdy neinstalovat do sdíleného toolcache self-hosted runneru.
run: |
python -m venv "$RUNNER_TEMP/venv"
echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH"
- name: Install
env:
INSTALL_COMMAND: ${{ inputs.install-command }}
run: |
python -m pip install --upgrade pip
eval "$INSTALL_COMMAND"
- name: Lint
if: inputs.lint-command != '' && matrix.python-version == inputs.build-python-version
env:
LINT_COMMAND: ${{ inputs.lint-command }}
run: eval "$LINT_COMMAND"
- name: Test
env:
TEST_COMMAND: ${{ inputs.test-command }}
run: eval "$TEST_COMMAND"

build:
name: Build and check distribution
runs-on: ${{ inputs.runs-on }}
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ inputs.build-python-version }}
- name: Build and twine check
run: |
python -m venv "$RUNNER_TEMP/venv"
. "$RUNNER_TEMP/venv/bin/activate"
python -m pip install --upgrade pip build twine
python -m build --outdir dist
twine check --strict dist/*
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ci-dist
path: dist/
retention-days: 7
162 changes: 162 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: Python publish to PyPI (reusable)

# Bezpečná publikace přes PyPI Trusted Publishing (OIDC), bez API tokenů.
# Volající workflow MUSÍ mít soubor .github/workflows/publish.yml - PyPI Trusted Publisher
# se registruje na název volajícího workflow, ne tohoto.
# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-publish.yml@v1

on:
workflow_call:
inputs:
package-name:
description: 'Distribuční název na PyPI (např. sysnet-pyutils)'
type: string
required: true
python-version:
type: string
default: '3.12'
environment:
description: 'GitHub environment publikačního jobu'
type: string
default: 'pypi'
repository-url:
description: 'Prázdné = produkční PyPI; https://test.pypi.org/legacy/ = TestPyPI'
type: string
default: ''
main-branch:
description: 'Větev, ze které smí release tag pocházet'
type: string
default: 'main'
dry-run:
description: 'Pouze ověření a build, bez publikace'
type: boolean
default: false
outputs:
version:
description: 'Publikovaná verze'
value: ${{ jobs.verify.outputs.version }}

permissions: {}

jobs:
verify:
name: Verify tag and build
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.dist.outputs.version }}
steps:
- name: Require release tag vX.Y.Z
env:
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
run: |
if [ "$REF_TYPE" != "tag" ] || ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Publikace je povolena jen z tagu vX.Y.Z (ref_type=$REF_TYPE, ref=$TAG)"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Require tagged commit on main branch
env:
MAIN_BRANCH: ${{ inputs.main-branch }}
run: |
git fetch --no-tags origin "$MAIN_BRANCH"
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$MAIN_BRANCH"; then
echo "::error::Tagovaný commit není na větvi $MAIN_BRANCH"
exit 1
fi
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ inputs.python-version }}
- name: Build
run: |
python -m pip install --upgrade pip build twine packaging
python -m build --outdir dist
twine check --strict dist/*
- name: Verify distribution matches tag and package name
id: dist
env:
PACKAGE_NAME: ${{ inputs.package-name }}
TAG: ${{ github.ref_name }}
run: |
python - <<'PY'
import glob, os, sys, tarfile, zipfile
from email.parser import Parser
from packaging.utils import canonicalize_name
from packaging.version import Version

expected_name = canonicalize_name(os.environ["PACKAGE_NAME"])
expected_version = Version(os.environ["TAG"].removeprefix("v"))
errors = []
wheels = glob.glob("dist/*.whl")
sdists = glob.glob("dist/*.tar.gz")
if len(wheels) != 1 or len(sdists) != 1:
errors.append(f"očekáván 1 wheel a 1 sdist, nalezeno {len(wheels)}/{len(sdists)}")
for path in wheels + sdists:
if path.endswith(".whl"):
with zipfile.ZipFile(path) as z:
name = next(n for n in z.namelist() if n.endswith(".dist-info/METADATA"))
meta = Parser().parsestr(z.read(name).decode())
else:
with tarfile.open(path) as t:
member = next(m for m in t.getmembers() if m.name.count("/") == 1 and m.name.endswith("/PKG-INFO"))
meta = Parser().parsestr(t.extractfile(member).read().decode())
if canonicalize_name(meta["Name"]) != expected_name:
errors.append(f"{path}: název {meta['Name']} != {expected_name}")
if Version(meta["Version"]) != expected_version:
errors.append(f"{path}: verze {meta['Version']} != tag {os.environ['TAG']}")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
out.write(f"version={expected_version}\n")
PY
- name: Refuse to republish an existing version
env:
PACKAGE_NAME: ${{ inputs.package-name }}
VERSION: ${{ steps.dist.outputs.version }}
REPOSITORY_URL: ${{ inputs.repository-url }}
run: |
host=pypi.org
case "$REPOSITORY_URL" in *test.pypi.org*) host=test.pypi.org ;; esac
code=$(curl -s -o /dev/null -w '%{http_code}' "https://$host/pypi/$PACKAGE_NAME/$VERSION/json")
if [ "$code" = "200" ]; then
echo "::error::Verze $VERSION balíčku $PACKAGE_NAME už na $host existuje; verzi nelze přepsat, vydejte novou."
exit 1
elif [ "$code" != "404" ]; then
echo "::error::Neočekávaná odpověď $host: HTTP $code"
exit 1
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: python-dist
path: dist/
if-no-files-found: error
retention-days: 7

publish:
name: Publish to PyPI
needs: verify
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ inputs.repository-url == '' && format('https://pypi.org/project/{0}/{1}/', inputs.package-name, needs.verify.outputs.version) || '' }}
permissions:
contents: read
id-token: write # OIDC pro Trusted Publishing
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: python-dist
path: dist/
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
repository-url: ${{ inputs.repository-url }}
packages-dir: dist/
attestations: false # pro privátní repozitáře nejsou podporovány
51 changes: 51 additions & 0 deletions .github/workflows/python-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Python GitHub Release (reusable)

# Vytvoří (nebo aktualizuje) GitHub Release z CHANGELOG.md. Idempotentní.
# Volání: uses: SYSNET-CZ/.github/.github/workflows/python-release.yml@v1

on:
workflow_call:
inputs:
package-name:
type: string
required: true
changelog-file:
type: string
default: 'CHANGELOG.md'

permissions: {}

jobs:
release:
name: GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Create or update release
env:
GH_TOKEN: ${{ github.token }}
PACKAGE_NAME: ${{ inputs.package-name }}
CHANGELOG: ${{ inputs.changelog-file }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
version="${TAG#v}"
{
if [ -f "$CHANGELOG" ]; then
# sekce začínající nadpisem "## [X.Y.Z]" nebo "## X.Y.Z" až do dalšího "## "
awk -v v="$version" '
/^## / { if (found) exit; if (index($0, "[" v "]") || $2 == v) { found=1; next } }
found { print }' "$CHANGELOG"
fi
echo
echo "**PyPI:** https://pypi.org/project/$PACKAGE_NAME/$version/"
} > "$RUNNER_TEMP/notes.md"
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release edit "$TAG" --repo "$REPO" --title "$PACKAGE_NAME $version" --notes-file "$RUNNER_TEMP/notes.md"
else
gh release create "$TAG" --repo "$REPO" --verify-tag --title "$PACKAGE_NAME $version" --notes-file "$RUNNER_TEMP/notes.md"
fi
35 changes: 35 additions & 0 deletions .github/workflows/test-workflows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Test reusable workflows

on:
pull_request:
push:
branches: [main]

permissions: {}

jobs:
actionlint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 -color

verify-logic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'
- run: |
python -m pip install pytest pyyaml build packaging setuptools
python -m pytest tests -q
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
.pytest_cache/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,15 @@ Všechny šablony jsou přednastaveny na **self-hosted runner** (`hermes-vm`).
1. Jdi do repozitáře → **Actions** → **New workflow**
2. V sekci **SYSNET-CZ** vyber příslušnou šablonu
3. Upravit dle potřeby a commitni

## Reusable workflows - Python knihovny na PyPI

| Workflow | Účel |
|---|---|
| `python-ci.yml` | lint, testy v matici verzí, build a `twine check` |
| `python-publish.yml` | ověření tagu/verze, publikace na PyPI přes OIDC (Trusted Publishing) |
| `python-release.yml` | GitHub Release z `CHANGELOG.md` |

Volat přes `@v1`. Dokumentace: [`docs/python-packaging.md`](docs/python-packaging.md),
[`docs/python-release-process.md`](docs/python-release-process.md),
[`docs/pypi-trusted-publishing.md`](docs/pypi-trusted-publishing.md).
Loading
Loading