diff --git a/.github/workflows/release-publish.yaml b/.github/workflows/release-publish.yaml new file mode 100644 index 00000000..0154c270 --- /dev/null +++ b/.github/workflows/release-publish.yaml @@ -0,0 +1,273 @@ +name: Flame Release Publish + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: flame-release-publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + CLICOLOR_FORCE: 1 + IMAGE_REGISTRY: docker.io/xflops + +jobs: + release-metadata: + name: Release Metadata + runs-on: ubuntu-latest + outputs: + cargo-version: ${{ steps.metadata.outputs.cargo-version }} + docker-tag: ${{ steps.metadata.outputs.docker-tag }} + publish-latest: ${{ steps.metadata.outputs.publish-latest }} + python-version: ${{ steps.metadata.outputs.python-version }} + stdng-version: ${{ steps.metadata.outputs.stdng-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Validate release metadata + id: metadata + env: + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python3 <<'PY' + import os + import tomllib + from pathlib import Path + + tag = os.environ["RELEASE_TAG"] + if not tag.startswith("v"): + raise SystemExit(f"release tag must start with 'v': {tag}") + + cargo_version = tomllib.loads(Path("sdk/rust/Cargo.toml").read_text())["package"]["version"] + macros_version = tomllib.loads(Path("sdk/rust/macros/Cargo.toml").read_text())["package"]["version"] + python_version = tomllib.loads(Path("sdk/python/pyproject.toml").read_text())["project"]["version"] + stdng_version = tomllib.loads(Path("stdng/Cargo.toml").read_text())["package"]["version"] + + expected_cargo = tag[1:] + expected_python = expected_cargo.replace("-rc", "rc") + + if cargo_version != expected_cargo: + raise SystemExit(f"flame-rs version {cargo_version} does not match release tag {tag}") + if macros_version != cargo_version: + raise SystemExit( + f"flame-rs-macros version {macros_version} does not match flame-rs {cargo_version}" + ) + if python_version != expected_python: + raise SystemExit( + f"flamepy version {python_version} does not match release tag {tag}; expected {expected_python}" + ) + + publish_latest = str(os.environ["RELEASE_PRERELEASE"].lower() != "true").lower() + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"cargo-version={cargo_version}\n") + output.write(f"docker-tag={tag}\n") + output.write(f"publish-latest={publish_latest}\n") + output.write(f"python-version={python_version}\n") + output.write(f"stdng-version={stdng_version}\n") + PY + + publish-python: + name: Publish flamepy + runs-on: ubuntu-latest + needs: release-metadata + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build flamepy + run: | + python -m pip install --upgrade pip uv + cd sdk/python + uv build --out-dir ../../dist/flamepy + + - name: Publish flamepy to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/flamepy + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + - name: Verify flamepy from PyPI + env: + PYTHON_VERSION: ${{ needs.release-metadata.outputs.python-version }} + run: | + set -euo pipefail + for attempt in $(seq 1 30); do + if uv run --no-project --with "flamepy==${PYTHON_VERSION}" \ + python -c "import flamepy; assert flamepy.__version__ == '${PYTHON_VERSION}'; print(flamepy.__version__)"; then + exit 0 + fi + echo "flamepy ${PYTHON_VERSION} is not available yet; retrying (${attempt}/30)" + sleep 10 + done + echo "flamepy ${PYTHON_VERSION} did not become available on PyPI" + exit 1 + + publish-rust: + name: Publish Rust crates + runs-on: ubuntu-latest + needs: release-metadata + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + CARGO_VERSION: ${{ needs.release-metadata.outputs.cargo-version }} + STDNG_VERSION: ${{ needs.release-metadata.outputs.stdng-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler pkg-config libssl-dev + + - name: Package and publish Rust crates + run: | + set -euo pipefail + + crate_exists() { + local crate="$1" + local version="$2" + curl -fsS "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1 + } + + wait_for_crate() { + local crate="$1" + local version="$2" + for attempt in $(seq 1 30); do + if crate_exists "${crate}" "${version}"; then + echo "${crate} ${version} is available on crates.io" + return 0 + fi + echo "${crate} ${version} is not available yet; retrying (${attempt}/30)" + sleep 10 + done + echo "${crate} ${version} did not become available on crates.io" + return 1 + } + + publish_crate() { + local crate="$1" + local version="$2" + local manifest="$3" + shift 3 + + if crate_exists "${crate}" "${version}"; then + echo "${crate} ${version} is already published; skipping publish" + return 0 + fi + + cargo publish --manifest-path "${manifest}" --token "${CARGO_REGISTRY_TOKEN}" "$@" + wait_for_crate "${crate}" "${version}" + } + + cargo package --manifest-path stdng/Cargo.toml + publish_crate stdng "${STDNG_VERSION}" stdng/Cargo.toml + wait_for_crate stdng "${STDNG_VERSION}" + + cargo package --manifest-path sdk/rust/macros/Cargo.toml + publish_crate flame-rs-macros "${CARGO_VERSION}" sdk/rust/macros/Cargo.toml + wait_for_crate flame-rs-macros "${CARGO_VERSION}" + + cargo package --manifest-path sdk/rust/Cargo.toml --features macros + publish_crate flame-rs "${CARGO_VERSION}" sdk/rust/Cargo.toml --features macros + wait_for_crate flame-rs "${CARGO_VERSION}" + + publish-images: + name: Publish ${{ matrix.image }} + runs-on: ubuntu-latest + needs: release-metadata + strategy: + fail-fast: false + matrix: + include: + - image: flame-session-manager + dockerfile: docker/Dockerfile.fsm + - image: flame-object-cache + dockerfile: docker/Dockerfile.foc + - image: flame-executor-manager + dockerfile: docker/Dockerfile.fem + - image: flame-console + dockerfile: docker/Dockerfile.console + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Collect Docker metadata + id: docker-meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_REGISTRY }}/${{ matrix.image }} + tags: | + type=raw,value=${{ needs.release-metadata.outputs.docker-tag }} + type=raw,value=latest,enable=${{ needs.release-metadata.outputs.publish-latest }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.docker-meta.outputs.tags }} + labels: ${{ steps.docker-meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} + provenance: false + + - name: Verify pushed image manifest + env: + DOCKER_TAG: ${{ needs.release-metadata.outputs.docker-tag }} + PUBLISH_LATEST: ${{ needs.release-metadata.outputs.publish-latest }} + run: | + set -euo pipefail + + inspect_manifest() { + local ref="$1" + local manifest + manifest="$(docker buildx imagetools inspect "${ref}")" + printf '%s\n' "${manifest}" + printf '%s\n' "${manifest}" | grep -q 'linux/amd64' + printf '%s\n' "${manifest}" | grep -q 'linux/arm64' + } + + image="${IMAGE_REGISTRY}/${{ matrix.image }}" + inspect_manifest "${image}:${DOCKER_TAG}" + if [ "${PUBLISH_LATEST}" = "true" ]; then + inspect_manifest "${image}:latest" + fi