diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9c5980..32e7a87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,22 +25,14 @@ jobs: - uses: actions/setup-python@v5 with: { python-version: "3.11" } - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ vars.WIF_PROVIDER }} - service_account: ${{ vars.DEPLOYER_SA_EMAIL }} - - uses: google-github-actions/setup-gcloud@v2 + - name: Validate release tag and source versions + run: python scripts/check_release.py + - uses: dtolnay/rust-toolchain@stable # goreleaser's `sboms` block shells out to syft; install it so the SBOM step succeeds. - name: install syft (SBOM) uses: anchore/sbom-action/download-syft@v0 - # google-github-actions/auth writes gha-creds-*.json into the workspace; exclude it so - # goreleaser's clean-tree check does not fail with "git is in a dirty state". - - name: keep the tree clean for goreleaser - run: echo 'gha-creds-*.json' >> .git/info/exclude - - name: build binaries + checksums + SBOM uses: goreleaser/goreleaser-action@v6 with: @@ -51,10 +43,20 @@ jobs: # release is disabled; without a token it 401s. contents:read is enough. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build and verify SDK artifacts before publication + run: python scripts/check_sdk_packages.py --output release-packages + - name: attest build provenance uses: actions/attest-build-provenance@v2 with: - subject-path: "dist/pitot_*.tar.gz, dist/pitot_*.zip, dist/checksums.txt" + subject-path: "dist/pitot_*.tar.gz, dist/pitot_*.zip, dist/checksums.txt, release-packages/*" + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.DEPLOYER_SA_EMAIL }} + - uses: google-github-actions/setup-gcloud@v2 - name: publish binaries -> Artifact Registry (generic) run: | @@ -71,26 +73,21 @@ jobs: # "latest" is resolved by the get-service from the AR version list — no channel manifest # (AR rejects non-semver version ids like "latest"). - - name: publish npm binding (@operatorstack/pitot) - working-directory: sdk/typescript + - name: publish verified npm binding (@operatorstack/pitot) + working-directory: release-packages run: | set -euo pipefail - npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version - # prepack builds the package; devDependencies must exist first. - npm install --no-audit --no-fund gcloud artifacts print-settings npm \ --project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \ --repository="${{ vars.AR_NPM_REPO }}" --scope=@operatorstack > .npmrc - npx -y google-artifactregistry-auth .npmrc - npm publish + npx -y google-artifactregistry-auth --repo-config="$PWD/.npmrc" --credential-config="$PWD/.npmrc" + npm publish ./operatorstack-pitot-*.tgz --ignore-scripts --userconfig="$PWD/.npmrc" \ + --registry="https://${{ vars.AR_LOCATION }}-npm.pkg.dev/${{ vars.AR_PROJECT }}/${{ vars.AR_NPM_REPO }}/" - - name: publish python binding (operatorstack-pitot) - working-directory: sdk/python + - name: publish verified python binding (operatorstack-pitot) run: | set -euo pipefail - sed -i "s/^version = .*/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml - pip install --quiet build twine keyrings.google-artifactregistry-auth - python -m build - twine upload \ + python -m pip install --quiet twine keyrings.google-artifactregistry-auth + python -m twine upload \ --repository-url "https://${{ vars.AR_LOCATION }}-python.pkg.dev/${{ vars.AR_PROJECT }}/${{ vars.AR_PYTHON_REPO }}/" \ - dist/* + release-packages/*.whl release-packages/*.tar.gz diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index fbfd3d6..9c9f4c4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -167,5 +167,9 @@ jobs: node-version: ${{ matrix.node }} - name: Verify native installation identity run: python -m unittest discover -s tests -p test_installation_identity.py + - uses: actions/setup-go@v5 + with: + go-version: "stable" + - uses: dtolnay/rust-toolchain@stable - name: Build and verify installed SDK artifacts run: python scripts/check_sdk_packages.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f50e12..d8461ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,3 +225,28 @@ python3 -m unittest discover -s sdk/tests -p 'test_*.py' The Verify workflow installs the Go, Python, Node, and Rust toolchains for these checks. Pitot E2E verifies real pinned agents across the supported platforms. The release workflow continues using the existing repository variables. + +### Release identity and package checks + +Before creating a stable `vMAJOR.MINOR.PATCH` release tag, update the version in +`sdk/python/pyproject.toml`, `sdk/typescript/package.json`, `sdk/rust/Cargo.toml` +and `pitotPackageVersion` in `cmd/pitot/workbench.go` together. The release workflow +requires those versions, the tag, and the checked-out commit to agree before it +authenticates or uploads anything. Manual dispatch must also select a release +tag. Prereleases are not supported by the current binary installer. Major versions +2 and above require a Go module path migration before release and are rejected. + +A released CLI generates exact matching SDK dependencies. An unstamped `dev` +build uses the source fallback above; when developing against unreleased SDK +changes, use local path/module overrides. Rust uses the matching repository Git +tag, with the crate under `sdk/rust`; Python and npm use the existing registries. + +`python scripts/check_sdk_packages.py` builds Python wheel/sdist, npm and Rust +artifacts, installs them into temporary consumers, and checks generated Rust +programs without model calls. It requires Python, Node/npm, Go and Rust, and may +fetch build dependencies. The three native CI runners execute this check. +The release workflow retains those checked artifacts with `--output` and publishes +the same npm/Python files, without rebuilding them during upload. A later registry +upload can still fail after an earlier upload succeeds; cross-registry publication +is not atomic. Resolve the upload failure and inspect existing version contents +before retrying. The package check itself never publishes. diff --git a/cmd/pitot/workbench.go b/cmd/pitot/workbench.go index 4e6af34..9ee3854 100644 --- a/cmd/pitot/workbench.go +++ b/cmd/pitot/workbench.go @@ -19,7 +19,7 @@ import ( "github.com/operatorstack/pitot/runtime" ) -// pitotPackageVersion is the published SDK version wired into generated +// pitotPackageVersion is the source SDK fallback version wired into generated // manifests so a freshly initialized project can resolve its dependency. const pitotPackageVersion = "0.1.0" @@ -325,6 +325,10 @@ func defaultRoleID(template string) string { // separately as a tenant fragment under .pitot/conf.d/. func projectFiles(lang, template string) (map[string]string, error) { files := map[string]string{} + sdkVersion := releaseVersion() + if sdkVersion == "dev" { + sdkVersion = pitotPackageVersion + } src, err := sourceTemplate(lang, template) if err != nil { @@ -333,18 +337,18 @@ func projectFiles(lang, template string) (map[string]string, error) { switch lang { case "python": files["main.py"] = src - files["requirements.txt"] = fmt.Sprintf("%s>=%s\n", pitotPythonDistribution, pitotPackageVersion) - files["pyproject.toml"] = pythonProjectManifest + files["requirements.txt"] = fmt.Sprintf("%s==%s\n", pitotPythonDistribution, sdkVersion) + files["pyproject.toml"] = fmt.Sprintf(pythonProjectManifest, sdkVersion) case "typescript": files["main.ts"] = src - files["package.json"] = tsProjectManifest + files["package.json"] = fmt.Sprintf(tsProjectManifest, sdkVersion) files["tsconfig.json"] = tsProjectTSConfig case "go": files["main.go"] = src - files["go.mod"] = goProjectManifest + files["go.mod"] = fmt.Sprintf(goProjectManifest, sdkVersion) case "rust": files["main.rs"] = src - files["Cargo.toml"] = rustProjectManifest + files["Cargo.toml"] = fmt.Sprintf(rustProjectManifest, sdkVersion) default: return nil, fmt.Errorf("pitot init: unsupported language %q", lang) } @@ -552,7 +556,7 @@ name = "pitot-project" version = "0.1.0" requires-python = ">=3.10" # The PyPI distribution is operatorstack-pitot; the import package is "pitot". -dependencies = ["operatorstack-pitot>=0.1.0"] +dependencies = ["operatorstack-pitot==%s"] ` const tsControllerTemplate = `import { runController, allow, ControlRequested } from '@operatorstack/pitot'; @@ -577,7 +581,7 @@ const tsProjectManifest = `{ "dev": "tsx main.ts" }, "dependencies": { - "@operatorstack/pitot": "^0.1.0" + "@operatorstack/pitot": "%s" }, "devDependencies": { "tsx": "^4.7.0", @@ -634,7 +638,7 @@ const goProjectManifest = `module pitot-project go 1.22 -require github.com/operatorstack/pitot v0.1.0 +require github.com/operatorstack/pitot v%s ` const rustControllerTemplate = `use pitot::{run_controller, allow, ControlRequested, Outcome}; @@ -651,7 +655,7 @@ fn main() { const rustConsumerTemplate = `use pitot::{run_consumer, Event}; fn handler(event: Event) { - eprintln!("Consumed event: {}", event.type_field); + eprintln!("Consumed event: {}", event.event_type); } fn main() { @@ -669,7 +673,7 @@ name = "pitot-project" path = "main.rs" [dependencies] -pitot = "0.1.0" +pitot = { git = "https://github.com/operatorstack/pitot", tag = "v%s" } serde_json = "1" ` diff --git a/cmd/pitot/workbench_build_test.go b/cmd/pitot/workbench_build_test.go index e2ec800..686121c 100644 --- a/cmd/pitot/workbench_build_test.go +++ b/cmd/pitot/workbench_build_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "os" "os/exec" "path/filepath" @@ -9,11 +10,10 @@ import ( "testing" ) -// labRoot resolves labs/15-pitot from the package working directory -// (cmd/pitot -> module root -> lab root), where the local SDK sources live. -func labRoot(t *testing.T) string { +// sdkRoot resolves the current repository from the package working directory. +func sdkRoot(t *testing.T) string { t.Helper() - p, err := filepath.Abs(filepath.Join("..", "..", "..")) + p, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatal(err) } @@ -65,9 +65,9 @@ func TestBuildGeneratedPythonImports(t *testing.T) { if err != nil { t.Skip("python3 not available") } - sdk := filepath.Join(labRoot(t), "pitot-distribution", "sdk", "python") + sdk := filepath.Join(sdkRoot(t), "sdk", "python") if _, err := os.Stat(filepath.Join(sdk, "pitot", "runner.py")); err != nil { - t.Skipf("local python SDK not present at %s", sdk) + t.Fatalf("local python SDK not present at %s", sdk) } proj := initInto(t, "python") @@ -90,9 +90,9 @@ func TestTypeCheckGeneratedTypeScript(t *testing.T) { if err != nil { t.Skip("node not available") } - sdk := filepath.Join(labRoot(t), "pitot-distribution", "sdk", "typescript") + sdk := filepath.Join(sdkRoot(t), "sdk", "typescript") if _, err := os.Stat(filepath.Join(sdk, "package.json")); err != nil { - t.Skipf("local typescript SDK not present at %s", sdk) + t.Fatalf("local typescript SDK not present at %s", sdk) } proj := initInto(t, "typescript") @@ -124,26 +124,25 @@ func TestTypeCheckGeneratedTypeScript(t *testing.T) { } // TestCargoCheckGeneratedRust type-checks the generated Rust controller against -// the local SDK via a path dependency. It skips when cargo or the local SDK is -// unavailable. +// the local SDK via a path dependency. It skips only when cargo is unavailable. func TestCargoCheckGeneratedRust(t *testing.T) { if _, err := exec.LookPath("cargo"); err != nil { t.Skip("cargo not available") } - sdk := filepath.Join(labRoot(t), "pitot-distribution", "sdk", "rust") + sdk := filepath.Join(sdkRoot(t), "sdk", "rust") if _, err := os.Stat(filepath.Join(sdk, "Cargo.toml")); err != nil { - t.Skipf("local rust SDK not present at %s", sdk) + t.Fatalf("local rust SDK not present at %s", sdk) } proj := initInto(t, "rust") - // Repoint the crates.io dependency at the local SDK by path. + // Repoint the release-tag dependency at the local SDK by path. manifestPath := filepath.Join(proj, "Cargo.toml") manifest, err := os.ReadFile(manifestPath) if err != nil { t.Fatal(err) } patched := strings.Replace(string(manifest), - `pitot = "0.1.0"`, + fmt.Sprintf(`pitot = { git = "https://github.com/operatorstack/pitot", tag = "v%s" }`, pitotPackageVersion), `pitot = { path = `+quoteToml(sdk)+` }`, 1) if patched == string(manifest) { t.Fatalf("could not repoint rust SDK dependency in:\n%s", manifest) diff --git a/cmd/pitot/workbench_version_test.go b/cmd/pitot/workbench_version_test.go new file mode 100644 index 0000000..caa0975 --- /dev/null +++ b/cmd/pitot/workbench_version_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "strings" + "testing" +) + +func TestGeneratedSDKReleaseIdentity(t *testing.T) { + old := version + t.Cleanup(func() { version = old }) + for _, v := range []string{"1.2.3", "dev", ""} { + version = v + want := v + if want == "dev" || want == "" { + want = pitotPackageVersion + } + for lang, checks := range map[string]map[string]string{ + "python": {"requirements.txt": "operatorstack-pitot==" + want, "pyproject.toml": "operatorstack-pitot==" + want}, + "typescript": {"package.json": `"@operatorstack/pitot": "` + want + `"`}, + "go": {"go.mod": "require github.com/operatorstack/pitot v" + want}, + "rust": {"Cargo.toml": `pitot = { git = "https://github.com/operatorstack/pitot", tag = "v` + want + `" }`}, + } { + files, err := projectFiles(lang, "blank-consumer") + if err != nil { + t.Fatal(err) + } + for file, dependency := range checks { + if !strings.Contains(files[file], dependency) { + t.Errorf("version %q: %s missing %s", v, file, dependency) + } + } + } + } +} diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..ffa999d --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Validate release identity before acquiring credentials or publishing artifacts.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +STABLE_VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + + +def source_version(root: Path = ROOT) -> str: + versions = { + "npm": json.loads((root / "sdk/typescript/package.json").read_text())["version"] + } + for name, path, pattern in ( + ("python", "sdk/python/pyproject.toml", r'^version = "([^"]+)"$'), + ("rust", "sdk/rust/Cargo.toml", r'^version = "([^"]+)"$'), + ( + "scaffold", + "cmd/pitot/workbench.go", + r'^const pitotPackageVersion = "([^"]+)"$', + ), + ): + match = re.search(pattern, (root / path).read_text(), re.M) + if not match: + raise ValueError(f"Missing {name} version") + versions[name] = match[1] + value = versions["npm"] + if not re.fullmatch(STABLE_VERSION, value) or len(set(versions.values())) != 1: + raise ValueError( + f"SDK release versions disagree or are unsupported: {versions}" + ) + return value + + +def check_release(ref: str, expected_commit: str, root: Path = ROOT) -> str: + match = re.fullmatch(r"refs/tags/v(" + STABLE_VERSION + r")", ref) + if not match: + raise ValueError("Release requires a stable vMAJOR.MINOR.PATCH tag ref") + value = match[1] + if int(value.split(".")[0]) >= 2: + raise ValueError("Release major >=2 requires a Go module path migration first") + if value != source_version(root): + raise ValueError( + "Release tag must match all SDK source versions and scaffold fallback" + ) + + def revision(name): + return subprocess.check_output( + ["git", "rev-parse", "--verify", name + "^{commit}"], cwd=root, text=True + ).strip() + + if ( + not expected_commit + or revision(ref) != revision("HEAD") + or revision("HEAD") != expected_commit + ): + raise ValueError("Release tag, checkout and event commit must match") + if subprocess.check_output( + ["git", "status", "--porcelain"], cwd=root, text=True + ).strip(): + raise ValueError("Release source must be clean") + return value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ref", default=os.environ.get("GITHUB_REF", "")) + parser.add_argument("--commit", default=os.environ.get("GITHUB_SHA", "")) + args = parser.parse_args() + try: + value = check_release(args.ref, args.commit) + except (ValueError, subprocess.CalledProcessError) as error: + raise SystemExit(str(error)) from error + print(f"Release source verified: {value}") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_rust_package.py b/scripts/check_rust_package.py new file mode 100644 index 0000000..7b83b51 --- /dev/null +++ b/scripts/check_rust_package.py @@ -0,0 +1,151 @@ +"""Exercise generated Rust programs against packaged, tag-addressed SDK bytes.""" + +from pathlib import Path +import json +import os +import shutil +import tarfile + + +def check_rust_package(work, root, run, env, version): + cargo = shutil.which("cargo") + go = shutil.which("go") + if not cargo or not go: + raise RuntimeError( + "Rust and Go toolchains are required for package verification" + ) + rust = work / "sdk/rust" + env["CARGO_TARGET_DIR"] = str(work / "rust-target") + run([cargo, "package", "--allow-dirty"], rust) + archive = work / "rust-target/package" / f"pitot-{version}.crate" + if not archive.is_file(): + raise RuntimeError("Rust package version does not match source") + # Use exactly the generated archive, in a repository with the public layout. + repository = work / "rust-git" + dependency = repository / "sdk/rust" + with tarfile.open(archive) as package: + prefix = f"pitot-{version}/" + for member in package.getmembers(): + if member.isfile() and member.name.startswith(prefix): + relative = Path(member.name[len(prefix) :]) + if relative.is_absolute() or ".." in relative.parts: + raise RuntimeError("Unexpected package path") + target = dependency / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(package.extractfile(member).read()) + if not (dependency / "src/lib.rs").is_file(): + raise RuntimeError("Rust archive missing library") + git = [ + "git", + "-c", + "user.name=Package Test", + "-c", + "user.email=package@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ] + run([*git, "init", "--quiet"], repository) + run([*git, "add", "."], repository) + run([*git, "commit", "--quiet", "-m", "Package fixture"], repository) + # Deliberately distinct from the source fallback, to test stamped CLI output. + stamped = "1.2.3" + run([*git, "tag", "v" + stamped], repository) + binary = work / ("pitot.exe" if os.name == "nt" else "pitot") + run( + [ + go, + "build", + "-ldflags", + "-X main.version=" + stamped, + "-o", + str(binary), + "./cmd/pitot", + ], + root, + ) + event = { + "pitot_version": "1", + "type": "action.requested", + "host": {"name": "package-test"}, + "observation": {"source": "host_hook", "fidelity": "direct"}, + "action": {"id": "observed", "kind": "shell"}, + } + for template in ("blank-controller", "blank-consumer", "shell-policy"): + project = work / template + run( + [ + str(binary), + "init", + "--language", + "rust", + "--template", + template, + "--dir", + template, + ], + work, + ) + manifest = project / "Cargo.toml" + original = manifest.read_text() + expected = f'pitot = {{ git = "https://github.com/operatorstack/pitot", tag = "v{stamped}" }}' + if expected not in original: + raise RuntimeError("Generated Rust dependency does not pin the CLI release") + # Substitute transport only; retain the emitted tag and nested-crate lookup. + patched = original.replace( + "https://github.com/operatorstack/pitot", repository.as_uri() + ) + manifest.write_text(patched) + if template == "blank-controller": + manifest.write_text(patched.replace('tag = "v1.2.3"', 'tag = "v9.9.9"')) + run([cargo, "check", "--quiet"], project, expected_failure=True) + manifest.write_text(patched) + run([cargo, "build", "--quiet"], project) + executable = ( + work + / "rust-target/debug" + / ("pitot-project.exe" if os.name == "nt" else "pitot-project") + ) + if template == "blank-consumer": + result = run( + [str(executable)], project, input_text=json.dumps(event) + "\n" + ) + if result.stdout or "Consumed event: action.requested" not in result.stderr: + raise RuntimeError("Packaged Rust Consumer did not observe silently") + else: + requests = [] + for decision in ("allow", "deny"): + data = { + **event, + "content": { + "mode": "full", + "full": "echo ok" if decision == "allow" else "PITOT_DENY_ME", + }, + } + requests.append( + json.dumps( + { + "pitot_version": "1", + "type": "control.requested", + "kind": "shell", + "action_id": decision, + "data": data, + } + ) + ) + result = run( + [str(executable)], project, input_text="\n".join(requests) + "\n" + ) + responses = [json.loads(line) for line in result.stdout.splitlines()] + outcomes = [response["outcome"] for response in responses] + expected_outcomes = ( + ["allow", "deny"] if template == "shell-policy" else ["allow", "allow"] + ) + if ( + outcomes != expected_outcomes + or [r["action_id"] for r in responses] != ["allow", "deny"] + or result.stderr + ): + raise RuntimeError("Packaged Rust Controller roundtrip differs") + return archive diff --git a/scripts/check_sdk_packages.py b/scripts/check_sdk_packages.py index 0245021..f22e2ba 100644 --- a/scripts/check_sdk_packages.py +++ b/scripts/check_sdk_packages.py @@ -3,6 +3,8 @@ from __future__ import annotations +import argparse +import email import json import os from pathlib import Path @@ -12,12 +14,22 @@ import tarfile import tempfile import venv +import zipfile + +from check_release import source_version +from check_rust_package import check_rust_package ROOT = Path(__file__).resolve().parents[1] def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", type=Path, help="retain verified packages for publication" + ) + args = parser.parse_args() + version = source_version() npm = shutil.which("npm") node = shutil.which("node") if not npm or not node: @@ -55,7 +67,7 @@ def run(arguments, cwd, *, input_text=None, expected_failure=False): capture_output=True, text=True, encoding="utf-8", - timeout=180, + timeout=300, ) if (result.returncode == 0) == expected_failure: print(result.stdout, file=sys.stderr) @@ -66,7 +78,8 @@ def run(arguments, cwd, *, input_text=None, expected_failure=False): # Copy tracked source bytes, including current edits, without caches, # untracked configuration or an existing dist that could hide a bad pack. sources = subprocess.check_output( - ["git", "ls-files", "-z", "sdk/python", "sdk/typescript"], cwd=ROOT + ["git", "ls-files", "-z", "sdk/python", "sdk/typescript", "sdk/rust"], + cwd=ROOT, ).decode() for relative in sources.split("\0"): if relative: @@ -85,6 +98,11 @@ def run(arguments, cwd, *, input_text=None, expected_failure=False): raise RuntimeError("Packing must produce exactly one tarball") with tarfile.open(tarballs[0]) as package: metadata = json.load(package.extractfile("package/package.json")) + if ( + metadata["name"] != "@operatorstack/pitot" + or metadata["version"] != version + ): + raise RuntimeError("npm package identity differs from release source") names = set(package.getnames()) for entry in (metadata["main"], metadata["types"]): if "package/" + entry.removeprefix("./") not in names: @@ -165,19 +183,61 @@ def python_at(location): python_at(build_environment), "-m", "pip", - "wheel", - "--no-deps", + "install", "--index-url", "https://pypi.org/simple", - "--wheel-dir", + "build", + ], + work, + ) + # build creates the wheel from the sdist, checking both distribution paths. + run( + [ + python_at(build_environment), + "-m", + "build", + "--outdir", str(wheels), str(work / "sdk/python"), ], work, ) wheel_files = list(wheels.glob("*.whl")) - if len(wheel_files) != 1: - raise RuntimeError("Build must produce exactly one wheel") + sdists = list(wheels.glob("*.tar.gz")) + if len(wheel_files) != 1 or len(sdists) != 1: + raise RuntimeError("Build must produce exactly one wheel and one sdist") + with zipfile.ZipFile(wheel_files[0]) as wheel: + metadata_paths = [ + name + for name in wheel.namelist() + if name.endswith(".dist-info/METADATA") + ] + if len(metadata_paths) != 1: + raise RuntimeError("Wheel metadata missing or ambiguous") + metadata = email.message_from_bytes(wheel.read(metadata_paths[0])) + if ( + metadata["Name"] != "operatorstack-pitot" + or metadata["Version"] != version + ): + raise RuntimeError("Wheel identity differs from release source") + with tarfile.open(sdists[0]) as sdist: + metadata_paths = [ + name + for name in sdist.getnames() + if name.count("/") == 1 and name.endswith("/PKG-INFO") + ] + if len(metadata_paths) != 1: + raise RuntimeError("Source distribution metadata missing or ambiguous") + metadata = email.message_from_bytes( + sdist.extractfile(metadata_paths[0]).read() + ) + if ( + metadata["Name"] != "operatorstack-pitot" + or metadata["Version"] != version + ): + raise RuntimeError( + "Source distribution identity differs from release source" + ) python = python_at(installed_environment) run([python, "-m", "pip", "install", "--no-deps", str(wheel_files[0])], work) run( @@ -246,6 +306,8 @@ def python_at(location): if result.stdout.strip() != "observed" or result.stderr: raise RuntimeError("Installed Consumer roundtrip differs") + crate = check_rust_package(work, ROOT, run, env, version) + # A failed compiler must stop packaging instead of publishing partial JS. (typescript / "src/index.ts").write_text( "export const broken: = 1;\n", encoding="utf-8" @@ -259,8 +321,12 @@ def python_at(location): ) if list(rejected.glob("*.tgz")): raise RuntimeError("Failed build produced a tarball") + if args.output: + args.output.mkdir(parents=True, exist_ok=False) + for artifact in [*tarballs, *wheel_files, *sdists, crate]: + shutil.copyfile(artifact, args.output / artifact.name) print( - "PASS: installed Python/TypeScript artifacts, public types, stdio and failed-build rejection" + "PASS: installed Python/TypeScript/Rust artifacts, generated Rust projects, identities, stdio and failed-build rejection" ) diff --git a/sdk/rust/README.md b/sdk/rust/README.md index bcc89f7..056a8bf 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -8,14 +8,18 @@ every other Pitot SDK — proven identical by the cross-language conformance sui ## Add the dependency -The crate is `pitot`. During local development, depend on it by path; a released -version installs from the project registry pinned to the CLI's version. +The crate is `pitot`, distributed from this repository through a Git dependency. +`pitot init --language rust` pins the tag to the CLI release version. Cargo finds +the crate under `sdk/rust`; no separate Cargo registry is required. ```toml [dependencies] -pitot = { path = "path/to/sdk/rust" } # or the pinned registry version +pitot = { git = "https://github.com/operatorstack/pitot", tag = "v0.1.0" } ``` +Use the tag matching `pitot version`. For a development checkout, use +`pitot = { path = "path/to/pitot/sdk/rust" }` instead. + ## Build a Controller A Controller receives a typed `ControlRequested` and returns an `Outcome`. Return @@ -56,5 +60,5 @@ fn main() { } ``` -See the repository [README](../../../public-readme-preview/README.md) for the full +See the repository [README](../../README.md) for the full runtime, host-hook wiring, and the enforcement boundary. diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..6ffe178 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,80 @@ +"""Exercise release admission with an owned repository; never publish.""" + +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "check_release", ROOT / "scripts/check_release.py" +) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ReleaseAdmission(unittest.TestCase): + def test_tag_identity_and_source_versions(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative, body in { + "sdk/typescript/package.json": json.dumps({"version": "1.2.3"}), + "sdk/python/pyproject.toml": 'version = "1.2.3"\n', + "sdk/rust/Cargo.toml": 'version = "1.2.3"\n', + "cmd/pitot/workbench.go": 'const pitotPackageVersion = "1.2.3"\n', + }.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + + def git(*args): + return subprocess.check_output( + [ + "git", + "-c", + "user.name=Release Test", + "-c", + "user.email=release@example.invalid", + "-c", + "commit.gpgsign=false", + *args, + ], + cwd=root, + text=True, + ).strip() + + git("init", "--quiet") + git("add", ".") + git("commit", "--quiet", "-m", "fixture") + git("tag", "v1.2.3") + commit = git("rev-parse", "HEAD") + self.assertEqual( + release.check_release("refs/tags/v1.2.3", commit, root), "1.2.3" + ) + for ref in ( + "refs/tags/v2.0.0", + "refs/heads/main", + "v1.2.3", + "refs/tags/vfoo", + "refs/tags/v1.2.3-rc1", + "refs/tags/v01.2.3", + "refs/tags/v1.2.4", + ): + with self.subTest(ref=ref), self.assertRaises(ValueError): + release.check_release(ref, commit, root) + with self.assertRaisesRegex(ValueError, "Go module path migration"): + release.check_release("refs/tags/v2.0.0", commit, root) + with self.assertRaises(ValueError): + release.check_release("refs/tags/v1.2.3", "0" * 40, root) + (root / "untracked").write_text("unexpected") + with self.assertRaises(ValueError): + release.check_release("refs/tags/v1.2.3", commit, root) + (root / "untracked").unlink() + (root / "sdk/rust/Cargo.toml").write_text('version = "1.2.4"\n') + with self.assertRaises(ValueError): + release.check_release("refs/tags/v1.2.3", commit, root) + + def test_repository_versions(self): + self.assertRegex(release.source_version(), "^" + release.STABLE_VERSION + "$")