diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f2d5d8..1edb52b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,40 @@ permissions: contents: read jobs: + validate-version: + name: Validate monorepo version + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Normalize release tag for SemVer validation + id: normalize-tag + run: | + raw_tag="${{ github.event.release.tag_name }}" + if [[ "$raw_tag" =~ ^[vV](.+)$ ]]; then + semver_tag="${BASH_REMATCH[1]}" + else + semver_tag="$raw_tag" + fi + echo "semver_tag=$semver_tag" >> "$GITHUB_OUTPUT" + + - name: Validate shared version against release tag + run: python3 scripts/wefa_version.py check --expect "${{ steps.normalize-tag.outputs.semver_tag }}" + publish-bff-image: name: Build, tag and publish BFF image if: ${{ !github.event.release.draft }} runs-on: ubuntu-latest + needs: validate-version + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v6 @@ -21,15 +51,15 @@ jobs: - name: Login to registry uses: docker/login-action@v3 with: - registry: ${{ secrets.OCI_REGISTRY_HOST }} - username: ${{ secrets.OCI_REGISTRY_USERNAME }} - password: ${{ secrets.OCI_REGISTRY_PASSWORD }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Compute image metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ secrets.OCI_REGISTRY_HOST }}/${{ secrets.OCI_BFF_IMAGE_REPOSITORY }} + images: ghcr.io/${{ github.repository }}/bff tags: | type=raw,value=${{ github.event.release.tag_name }} type=raw,value=latest,enable=${{ !github.event.release.prerelease }} @@ -46,6 +76,7 @@ jobs: publish-npm: name: Publish Vue package to npm runs-on: ubuntu-latest + needs: validate-version permissions: id-token: write defaults: @@ -82,6 +113,7 @@ jobs: publish-pypi: name: Publish Django package to PyPI runs-on: ubuntu-latest + needs: validate-version environment: name: pypi url: https://pypi.org/p/nside-wefa diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 0000000..bcffd54 --- /dev/null +++ b/.github/workflows/scripts.yml @@ -0,0 +1,29 @@ +name: Scripts CI + +on: + pull_request: + paths: + - 'scripts/**' + - '.github/workflows/scripts.yml' + push: + branches: [ main, develop ] + paths: + - 'scripts/**' + - '.github/workflows/scripts.yml' + workflow_dispatch: + +jobs: + tests: + name: Scripts Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run scripts unit tests + run: python3 -m unittest discover -s scripts -p 'test_*.py' diff --git a/README.md b/README.md index 910f5f9..66826ef 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,32 @@ See the [Vue README](vue/README.md) for build scripts, Storybook, and component The demo apps in both workspaces illustrate how to compose the packages together. +## Release Versioning + +This monorepo uses one shared version across `vue/`, `django/`, and `bff/`. +Use the root orchestrator script for any version change: + +```bash +python3 scripts/wefa_version.py -h +python3 scripts/wefa_version.py show +python3 scripts/wefa_version.py check --expect +python3 scripts/wefa_version.py bump patch +python3 scripts/wefa_version.py set 1.0.0-rc.1 +``` + +Use `--dry-run` to preview changes and `--allow-dirty-version-files` only when you intentionally need to override preflight checks. + +Use SemVer for CLI inputs and release tags (for example `1.2.3-rc.1`). For prereleases, only +`alpha.`, `beta.`, and `rc.` are supported. Python project files are written in PEP 440 +equivalent form (`1.2.3a1`, `1.2.3b1`, `1.2.3rc1`) by the orchestrator. + +### BFF Container Image + +When a GitHub release is published, CI builds and pushes the BFF Docker image to GitHub Container Registry (GHCR): + +- `ghcr.io/n-side-dev/wefa/bff:` +- `ghcr.io/n-side-dev/wefa/bff:latest` for non-prerelease tags only + ## Contributing Contributions are welcome! Start with open issues or propose new ideas through GitHub discussions. Please read [Django CONTRIBUTE](django/CONTRIBUTE.md) and/or [Vue CONTRIBUTE](vue/CONTRIBUTE.md) for the current contribution workflow. diff --git a/django/CONTRIBUTE.md b/django/CONTRIBUTE.md index bfa409e..165c16a 100644 --- a/django/CONTRIBUTE.md +++ b/django/CONTRIBUTE.md @@ -337,6 +337,7 @@ When making changes: - Update the main README if adding new features - Keep examples up to date - Update version numbers as needed +- For monorepo releases, run version bumps from the repository root with `python3 scripts/wefa_version.py` ### API Documentation @@ -354,4 +355,4 @@ If you have questions or need help: 3. Create a new issue with detailed information 4. Tag maintainers if urgent -Thank you for contributing to N-SIDE WeFa! \ No newline at end of file +Thank you for contributing to N-SIDE WeFa! diff --git a/scripts/test_wefa_version.py b/scripts/test_wefa_version.py new file mode 100644 index 0000000..63b7e5f --- /dev/null +++ b/scripts/test_wefa_version.py @@ -0,0 +1,104 @@ +import argparse +import importlib.util +import sys +import tempfile +from pathlib import Path +from unittest import TestCase, mock + + +MODULE_PATH = Path(__file__).with_name("wefa_version.py") +SPEC = importlib.util.spec_from_file_location("wefa_version_module", MODULE_PATH) +assert SPEC and SPEC.loader +wefa_version = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = wefa_version +SPEC.loader.exec_module(wefa_version) + + +MIXED_RC_VERSIONS = { + "vue/package.json": "1.0.0-rc.1", + "vue/package-lock.json": "1.0.0-rc.1", + "django/pyproject.toml": "1.0.0rc1", + "django/uv.lock": "1.0.0rc1", + "django/nside_wefa/__init__.py": "1.0.0rc1", + "bff/pyproject.toml": "1.0.0rc1", + "bff/uv.lock": "1.0.0rc1", +} + + +class WefaVersionTests(TestCase): + def test_semver_to_pep440_conversion(self) -> None: + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-rc.4", flag_name="version"), + "1.2.3rc4", + ) + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-alpha.2", flag_name="version"), + "1.2.3a2", + ) + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-beta.7", flag_name="version"), + "1.2.3b7", + ) + + def test_pep440_to_semver_conversion(self) -> None: + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3rc4", flag_name="version"), + "1.2.3-rc.4", + ) + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3a2", flag_name="version"), + "1.2.3-alpha.2", + ) + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3b7", flag_name="version"), + "1.2.3-beta.7", + ) + + def test_rejects_unsupported_semver_prerelease_label(self) -> None: + with self.assertRaisesRegex(ValueError, "prerelease label must be one of"): + wefa_version.build_version_targets("1.2.3-preview.1", flag_name="version") + + def test_supported_prerelease_label_is_canonicalized_to_lowercase(self) -> None: + targets = wefa_version.build_version_targets("1.2.3-RC.1", flag_name="version") + self.assertEqual(targets.semver, "1.2.3-rc.1") + self.assertEqual(targets.python, "1.2.3rc1") + + def test_unified_version_accepts_mixed_semver_and_pep440(self) -> None: + self.assertEqual(wefa_version.unified_version(MIXED_RC_VERSIONS), "1.0.0-rc.1") + + def test_check_expect_semver_matches_pep440_python_sources(self) -> None: + args = argparse.Namespace(expect="1.0.0-rc.1") + with mock.patch.object(wefa_version, "collect_versions", return_value=MIXED_RC_VERSIONS): + self.assertEqual(wefa_version.cmd_check(args), 0) + + def test_post_update_assertions_require_expected_serialization(self) -> None: + targets = wefa_version.build_version_targets("1.0.0-rc.1", flag_name="version") + + with mock.patch.object(wefa_version, "collect_versions", return_value=MIXED_RC_VERSIONS): + wefa_version.post_update_assertions(targets) + + wrong_versions = dict(MIXED_RC_VERSIONS) + wrong_versions["django/pyproject.toml"] = "1.0.0-rc.1" + with mock.patch.object(wefa_version, "collect_versions", return_value=wrong_versions): + with self.assertRaisesRegex(RuntimeError, "expected 1.0.0rc1"): + wefa_version.post_update_assertions(targets) + + def test_transactional_update_restores_files_on_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "version.txt" + path.write_text("before\n", encoding="utf-8") + source = wefa_version.VersionSource( + path=path, + reader=lambda p: p.read_text(encoding="utf-8").strip(), + version_format=wefa_version.VERSION_FORMAT_SEMVER, + ) + + def mutate() -> None: + path.write_text("after\n", encoding="utf-8") + raise RuntimeError("boom") + + with mock.patch.object(wefa_version, "VERSION_SOURCES", (source,)): + with self.assertRaisesRegex(RuntimeError, "restored tracked version files"): + wefa_version.run_transactional_version_update(mutate) + + self.assertEqual(path.read_text(encoding="utf-8"), "before\n") diff --git a/scripts/wefa_version.py b/scripts/wefa_version.py new file mode 100644 index 0000000..6299d73 --- /dev/null +++ b/scripts/wefa_version.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Unified monorepo version orchestrator for vue, django, and bff.""" + +from __future__ import annotations + +import argparse +import json +import re +import shlex +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +ROOT = Path(__file__).resolve().parent.parent + +SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)" + r"(?:-(?P" + r"(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*" + r"))?$" +) + +PEP440_RE = re.compile( + r"^(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)" + r"(?:(?P