diff --git a/.github/beta-release/bump.py b/.github/beta-release/bump.py new file mode 100644 index 00000000..515e1010 --- /dev/null +++ b/.github/beta-release/bump.py @@ -0,0 +1,128 @@ +import argparse +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] + +CORE = { + "core/macros": "zen-macros", + "core/types": "zen-types", + "core/expression": "zen-expression", + "core/template": "zen-tmpl", + "core/engine": "zen-engine", +} + +BINDINGS = { + "nodejs": "bindings/nodejs", + "python": "bindings/python", +} + +TAG_PREFIX = { + "rust": "zen-engine", + "nodejs": "nodejs", + "python": "python", +} + +VERSION = re.compile(r"^\d+\.\d+\.\d+-beta\.\d+$") +PACKAGE_VERSION = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) + + +def fail(message): + print(f"error: {message}", file=sys.stderr) + sys.exit(1) + + +def tags(pattern): + out = subprocess.run( + ["git", "tag", "--list", pattern], cwd=ROOT, check=True, capture_output=True, text=True + ).stdout + return [line.strip() for line in out.splitlines() if line.strip()] + + +def next_version(branch): + match = re.fullmatch(r"release/(\d+)\.(\d+)", branch) + if not match: + fail(f"cannot derive a version from branch '{branch}' (expected release/.)") + base = f"{match.group(1)}.{match.group(2)}.0" + numbers = [] + for prefix in TAG_PREFIX.values(): + for tag in tags(f"{prefix}-v{base}-beta.*"): + found = re.fullmatch(rf"{re.escape(prefix)}-v{re.escape(base)}-beta\.(\d+)", tag) + if found: + numbers.append(int(found.group(1))) + return f"{base}-beta.{max(numbers) + 1 if numbers else 0}" + + +def set_package_version(path, version): + manifest = ROOT / path / "Cargo.toml" + text = manifest.read_text() + updated, count = PACKAGE_VERSION.subn(f'version = "{version}"', text, count=1) + if count != 1: + fail(f"no package version in {manifest}") + manifest.write_text(updated) + + +def set_requirements(version): + names = "|".join(re.escape(name) for name in CORE.values()) + requirement = re.compile( + rf'^((?:{names})\s*=\s*\{{[^}}\n]*\bversion\s*=\s*")[^"]*(")', re.MULTILINE + ) + for manifest in ROOT.glob("**/Cargo.toml"): + if "target" in manifest.relative_to(ROOT).parts: + continue + text = manifest.read_text() + updated = requirement.sub(rf"\g<1>{version}\g<2>", text) + if updated != text: + manifest.write_text(updated) + + +def set_package_json(path, version): + package = ROOT / path / "package.json" + text = package.read_text() + updated, count = re.subn( + r'^ "version": "[^"]*"', f' "version": "{version}"', text, count=1, flags=re.MULTILINE + ) + if count != 1 or json.loads(updated)["version"] != version: + fail(f"no top-level version in {package}") + package.write_text(updated) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--branch", required=True) + parser.add_argument("--version", default="") + parser.add_argument("--targets", required=True) + args = parser.parse_args() + + targets = [t for t in args.targets.split(",") if t] + unknown = [t for t in targets if t not in TAG_PREFIX] + if not targets or unknown: + fail(f"targets must be a non-empty subset of {sorted(TAG_PREFIX)}") + + version = args.version.strip() or next_version(args.branch) + if not VERSION.fullmatch(version): + fail(f"'{version}' is not a beta version like 2.2.0-beta.0") + + release_tags = [f"{TAG_PREFIX[t]}-v{version}" for t in targets] + existing = [tag for tag in release_tags if tags(tag)] + if existing: + fail(f"tags already exist: {' '.join(existing)}") + + for path in CORE: + set_package_version(path, version) + set_requirements(version) + for target in targets: + if target in BINDINGS: + set_package_version(BINDINGS[target], version) + if "nodejs" in targets: + set_package_json(BINDINGS["nodejs"], version) + + print(f"version={version}") + print(f"tags={' '.join(release_tags)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index d5193f19..08783270 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -4,6 +4,7 @@ on: push: tags: - 'zen-engine-v*' + - '!zen-engine-v*-beta*' workflow_dispatch: permissions: diff --git a/.github/workflows/beta-release.yaml b/.github/workflows/beta-release.yaml new file mode 100644 index 00000000..78b3bed7 --- /dev/null +++ b/.github/workflows/beta-release.yaml @@ -0,0 +1,92 @@ +name: Beta release + +on: + workflow_dispatch: + inputs: + rust: + description: 'Publish the core crates to crates.io' + type: boolean + default: true + nodejs: + description: 'Publish @gorules/zen-engine to npm (dist-tag beta)' + type: boolean + default: true + python: + description: 'Publish zen-engine to PyPI' + type: boolean + default: false + version: + description: 'Exact version, e.g. 2.2.0-beta.3 (empty: next beta for this branch)' + type: string + required: false + default: '' + dry-run: + description: 'Show the version and file changes without tagging' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: beta-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + beta: + name: Tag beta + if: startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.PAT }} + + - name: Bump versions + id: bump + env: + RUST: ${{ inputs.rust }} + NODEJS: ${{ inputs.nodejs }} + PYTHON: ${{ inputs.python }} + VERSION: ${{ inputs.version }} + run: | + targets=() + [ "$RUST" = "true" ] && targets+=(rust) + [ "$NODEJS" = "true" ] && targets+=(nodejs) + [ "$PYTHON" = "true" ] && targets+=(python) + python3 .github/beta-release/bump.py \ + --branch "$GITHUB_REF_NAME" \ + --version "$VERSION" \ + --targets "$(IFS=,; echo "${targets[*]}")" | tee -a "$GITHUB_OUTPUT" + + - name: Update Cargo.lock + run: cargo update --workspace + + - name: Show changes + run: git diff + + - name: Commit and push tags + if: ${{ !inputs.dry-run }} + env: + VERSION: ${{ steps.bump.outputs.version }} + TAGS: ${{ steps.bump.outputs.tags }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -am "chore(release): $VERSION" + for tag in $TAGS; do + git tag -a "$tag" -m "$tag" + done + git push origin $TAGS + + - name: Create pre-releases + if: ${{ !inputs.dry-run }} + env: + GH_TOKEN: ${{ secrets.PAT }} + TAGS: ${{ steps.bump.outputs.tags }} + run: | + for tag in $TAGS; do + gh release create "$tag" --prerelease --verify-tag --generate-notes --title "$tag" + done