Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 23 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand All @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 15 additions & 11 deletions cmd/pitot/workbench.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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';
Expand All @@ -577,7 +581,7 @@ const tsProjectManifest = `{
"dev": "tsx main.ts"
},
"dependencies": {
"@operatorstack/pitot": "^0.1.0"
"@operatorstack/pitot": "%s"
},
"devDependencies": {
"tsx": "^4.7.0",
Expand Down Expand Up @@ -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};
Expand All @@ -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() {
Expand All @@ -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"
`

Expand Down
27 changes: 13 additions & 14 deletions cmd/pitot/workbench_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ package main

import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"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)
}
Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions cmd/pitot/workbench_version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
Loading
Loading