diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 00000000..09a466ec --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,58 @@ +name: Installed package acceptance + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + outputs: + package-artifact-id: ${{ steps.upload-package.outputs.artifact-id }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.14' + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - run: uv sync --frozen + - run: uv run python -m build --wheel --outdir candidate + - run: bash scripts/build-acceptance.sh candidate/*.whl candidate + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload-package + with: + name: client-acceptance + path: candidate/* + if-no-files-found: error + + acceptance-check: + needs: build + uses: ./.github/workflows/verify-acceptance.yml + with: + package-artifact-id: ${{ needs.build.outputs.package-artifact-id }} + + attest: + if: github.event_name != 'pull_request' + needs: [build, acceptance-check] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.package-artifact-id }} + merge-multiple: true + path: candidate + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: candidate/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8cfb6da3..fe89f71f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,9 @@ jobs: uses: ./.github/workflows/ci.yml build: + outputs: + package-artifact-id: ${{ steps.upload-package.outputs.artifact-id }} + bundle-artifact-id: ${{ steps.upload-acceptance.outputs.artifact-id }} needs: [validate, check] runs-on: ubuntu-latest timeout-minutes: 15 @@ -54,18 +57,75 @@ jobs: - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - run: uv sync --frozen - run: uv run python -m build + - run: bash scripts/build-acceptance.sh dist/*.whl acceptance-output - name: Check package identity and release version env: RELEASE_TAG: ${{ github.event.release.tag_name }} - run: bash scripts/check_package.sh "${RELEASE_TAG#v}" + run: | + python -c 'import os,tomllib; p=tomllib.load(open("pyproject.toml","rb"))["project"]; assert p["name"]=="volcano-sdk-python" and "v"+p["version"]==os.environ["RELEASE_TAG"]' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload-package with: name: release-package path: dist/* if-no-files-found: error + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload-acceptance + with: + name: acceptance-tests + path: acceptance-output/sdk-acceptance.tar.gz + if-no-files-found: error - publish: + acceptance-check: needs: build + uses: ./.github/workflows/verify-acceptance.yml + with: + package-artifact-id: ${{ needs.build.outputs.package-artifact-id }} + bundle-artifact-id: ${{ needs.build.outputs.bundle-artifact-id }} + release-version: ${{ github.event.release.tag_name }} + + attest: + needs: [build, acceptance-check] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.package-artifact-id }} + merge-multiple: true + path: release + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.bundle-artifact-id }} + merge-multiple: true + path: release + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release/* + - name: Publish acceptance bundle + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name' | grep -Fxq sdk-acceptance.tar.gz; then + mkdir previous + gh release download "$RELEASE_TAG" --pattern sdk-acceptance.tar.gz --dir previous + gh attestation verify previous/sdk-acceptance.tar.gz --repo "$GH_REPO" --signer-workflow "$GH_REPO/.github/workflows/publish.yml" --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$GITHUB_SHA" --deny-self-hosted-runners + # Keep the authenticated original when a retry rebuilds the same SDK. + tar -xOf previous/sdk-acceptance.tar.gz ./acceptance.json | jq -S . > previous.json + tar -xOf release/sdk-acceptance.tar.gz ./acceptance.json | jq -S . > current.json + cmp previous.json current.json + exit 0 + fi + gh release upload "$RELEASE_TAG" release/sdk-acceptance.tar.gz + + publish: + needs: [build, attest] runs-on: ubuntu-latest timeout-minutes: 10 environment: @@ -77,11 +137,39 @@ jobs: # Publishing receives checked artifacts without executing SDK source. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: release-package + artifact-ids: ${{ needs.build.outputs.package-artifact-id }} + merge-multiple: true path: dist - name: Publish distributions to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + registry-smoke: + needs: [publish, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.bundle-artifact-id }} + merge-multiple: true + path: acceptance-output + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.14' + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - name: Check the registry download in a fresh consumer + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + mkdir consumer + tar -xzf acceptance-output/sdk-acceptance.tar.gz -C consumer + cd consumer + python -m pip download --no-deps --only-binary :all: --index-url https://pypi.org/simple "volcano-sdk-python==${RELEASE_TAG#v}" + python -c 'import hashlib,json,pathlib; a=json.loads(pathlib.Path("acceptance.json").read_text()); assert hashlib.sha256(pathlib.Path(a["filename"]).read_bytes()).hexdigest()==a["sha256"], "Registry package differs from build"' + bash scripts/smoke-wheel.sh ./*.whl + release-link: needs: publish runs-on: ubuntu-latest diff --git a/.github/workflows/verify-acceptance.yml b/.github/workflows/verify-acceptance.yml new file mode 100644 index 00000000..d5230479 --- /dev/null +++ b/.github/workflows/verify-acceptance.yml @@ -0,0 +1,53 @@ +name: Verify immutable acceptance artifacts + +on: + workflow_call: + inputs: + package-artifact-id: + required: true + type: string + bundle-artifact-id: + default: '' + type: string + release-version: + default: '' + type: string + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.14' + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ inputs.package-artifact-id }} + merge-multiple: true + path: package + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + if: inputs.bundle-artifact-id != '' + with: + artifact-ids: ${{ inputs.bundle-artifact-id }} + merge-multiple: true + path: acceptance + # Fresh dependencies can modify these copies, never the uploaded artifacts. + - name: Verify the installed package and fresh consumer + env: + ACCEPTANCE_BUNDLE: ${{ inputs.bundle-artifact-id != '' && 'acceptance/sdk-acceptance.tar.gz' || 'package/sdk-acceptance.tar.gz' }} + run: bash scripts/test-acceptance.sh package/*.whl "$ACCEPTANCE_BUNDLE" + - name: Check wheel, source distribution and installed types + if: inputs.release-version != '' + env: + RELEASE_TAG: ${{ inputs.release-version }} + run: | + uv sync --frozen + bash scripts/check_package.sh "${RELEASE_TAG#v}" package diff --git a/acceptance/pyproject.toml b/acceptance/pyproject.toml new file mode 100644 index 00000000..e9a389fb --- /dev/null +++ b/acceptance/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "volcano-sdk-acceptance" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = ["behave>=1.3.3,<2", "pytest>=8.3,<10"] + +[tool.uv] +package = false diff --git a/maintainers/acceptance.md b/maintainers/acceptance.md new file mode 100644 index 00000000..5937895e --- /dev/null +++ b/maintainers/acceptance.md @@ -0,0 +1,23 @@ +--- +title: Installed-package acceptance tests +description: Build and publish the locked consumer project used by Hosting. +--- + +Run `bash scripts/build-acceptance.sh ` after +building the SDK. The command creates a standalone consumer and native dependency +lock without installing or executing newly resolved dependencies. It exports +`sdk-acceptance.tar.gz` with the existing contract bindings, without SDK runtime +source or package bytes. + +CI uploads the package and bundle before running +`bash scripts/test-acceptance.sh ` in a separate job. +That job checks frozen installation, package loading, and the public quickstart. +Signing and publishing download the original immutable artifact IDs after those +checks pass; dependencies cannot modify the originals. + +Hosting authenticates the bundle and customer-registry package, restores that +package at the filename recorded in `acceptance.json`, then performs a frozen +install with `uv sync --frozen`. Place the wheel beside `pyproject.toml` and +`uv.lock`, then run tests with `uv run --no-sync`. Build candidate bundles +with the same command before publishing; package and bundle must come from the +same source commit. Never modify imports or lockfile text in the consumer. diff --git a/scripts/build-acceptance.sh b/scripts/build-acceptance.sh new file mode 100755 index 00000000..a3f0471b --- /dev/null +++ b/scripts/build-acceptance.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ "$#" -ne 2 ]; then echo 'usage: build-acceptance.sh sdk.whl output-directory' >&2; exit 1; fi +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +artifact="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +mkdir -p "$2" +output="$(cd "$2" && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cp "$repo_dir/acceptance/pyproject.toml" "$work/" +cp "$artifact" "$work/" +cp -R "$repo_dir/features" "$work/" +mkdir -p "$work/tests/package" "$work/docs" "$work/scripts" +cp "$repo_dir/tests/package/quickstart.py" "$work/tests/package/" +cp "$repo_dir/docs/README.md" "$work/docs/" +cp "$repo_dir/scripts/smoke-wheel.sh" "$work/scripts/" +cd "$work" +uv add --no-sync --no-build "./$(basename "$artifact")" +python3 - "$(basename "$artifact")" <<'PYTHON' +import email, hashlib, json, sys, zipfile +from pathlib import Path +artifact = Path(sys.argv[1]) +with zipfile.ZipFile(artifact) as wheel: + names = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")] + assert len(names) == 1 + metadata = email.message_from_bytes(wheel.read(names[0])) +assert metadata["Name"] == "volcano-sdk-python" +Path("acceptance.json").write_text(json.dumps({"schema": 1, "language": "python", "package": metadata["Name"], "version": metadata["Version"], "filename": artifact.name, "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest()}, indent=2)) +PYTHON +rm "$(basename "$artifact")" +tar -czf "$output/sdk-acceptance.tar.gz" . diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh new file mode 100755 index 00000000..1ad84237 --- /dev/null +++ b/scripts/smoke-wheel.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ "$#" -ne 1 ]; then echo 'usage: smoke-wheel.sh sdk.whl' >&2; exit 1; fi +artifact="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +uv venv "$work/venv" +uv pip install --python "$work/venv/bin/python" --only-binary :all: "$artifact" +cd "$work" +env -i PATH="$PATH" HOME="$work" "$work/venv/bin/python" -I "$repo_dir/tests/package/quickstart.py" diff --git a/scripts/test-acceptance.sh b/scripts/test-acceptance.sh new file mode 100755 index 00000000..f1c1fef8 --- /dev/null +++ b/scripts/test-acceptance.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ "$#" -ne 2 ]; then echo 'usage: test-acceptance.sh package-file sdk-acceptance.tar.gz' >&2; exit 1; fi +artifact="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +bundle="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +tar -xzf "$bundle" -C "$work" +cd "$work" +cp "$artifact" . +python3 -c 'import hashlib,json,pathlib; a=json.loads(pathlib.Path("acceptance.json").read_text()); assert hashlib.sha256(pathlib.Path(a["filename"]).read_bytes()).hexdigest()==a["sha256"], "Package differs from acceptance bundle"' +uv sync --frozen --no-build +uv run --no-sync python -I -c 'from pathlib import Path; import volcano_sdk; assert Path(volcano_sdk.__file__).resolve().is_relative_to(Path.cwd()/".venv")' +bash scripts/smoke-wheel.sh "$(basename "$artifact")"