Skip to content

Publish SDKs to OperatorStack Registry #34

Publish SDKs to OperatorStack Registry

Publish SDKs to OperatorStack Registry #34

name: Publish SDKs to OperatorStack Registry
on:
pull_request:
paths: [".github/workflows/private-registry.yml"]
push:
tags: ["v*"]
workflow_run:
workflows: ["Release Yield"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "Existing release version without the leading v"
required: true
default: "0.1.8"
type: string
permissions:
contents: read
id-token: write
concurrency:
group: publish-yield-sdks
cancel-in-progress: false
jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Verify the current release source
run: |
go test ./...
node --test packaging/*.test.mjs
- name: Check the one-package release contract
run: |
set -euo pipefail
grep -F 'CGO_ENABLED=0' .github/workflows/private-registry.yml
grep -F 'packaging/assemble.mjs' .github/workflows/private-registry.yml
grep -F 'Install and test all language packages' .github/workflows/private-registry.yml
publish:
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
previous_version: ${{ steps.version.outputs.previous_version }}
source_sha: ${{ steps.version.outputs.source_sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: actions/setup-node@v4
with:
node-version: "24"
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: dtolnay/rust-toolchain@stable
- id: version
name: Resolve published version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" == "workflow_run" ]]; then
tag="$(git tag --points-at HEAD --list 'v[0-9]*' --sort=-v:refname | head -n 1)"
test -n "$tag"
version="${tag#v}"
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
version="${GITHUB_REF_NAME#v}"
else
version="$REQUESTED_VERSION"
fi
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
git rev-parse --verify "refs/tags/v${version}"
test "$(git rev-list -n 1 "v${version}")" = "$(git rev-parse HEAD)"
previous="$(git tag --list 'v[0-9]*' --sort=-v:refname \
| sed 's/^v//' \
| awk -F. '$1 > 0 || $2 > 1 || ($2 == 1 && $3 >= 23)' \
| awk -F. -v current="$version" '
BEGIN { split(current, part, ".") }
$1 < part[1] || ($1 == part[1] && $2 < part[2]) || ($1 == part[1] && $2 == part[2] && $3 < part[3]) { print; exit }
')"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "previous_version=$previous" >> "$GITHUB_OUTPUT"
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- 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
with:
install_components: package-go-module
- name: Verify Go SDK and runtime
run: go test ./...
- name: Build immutable runtimes and language packages
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist/bin
while read -r goos goarch; do
suffix=""
if [[ "$goos" == "windows" ]]; then suffix=".exe"; fi
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \
-trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o "dist/bin/yskill-${goos}-${goarch}${suffix}" ./cmd/yskill
done <<'TARGETS'
darwin amd64
darwin arm64
linux amd64
linux arm64
windows amd64
windows arm64
TARGETS
node packaging/assemble.mjs --version "$VERSION" --binaries dist/bin --output dist/packages
- name: Keep runtime checksums with the release run
uses: actions/upload-artifact@v4
with:
name: yskill-${{ steps.version.outputs.version }}-runtimes
path: |
dist/bin/
dist/packages/SHA256SUMS.json
if-no-files-found: error
- name: Publish Go module
run: |
set -euo pipefail
version="${{ steps.version.outputs.version }}"
if gcloud artifacts versions describe "v${version}" \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_GO_REPO }}" --package=github.com/operatorstack/yield \
>/dev/null 2>&1; then
echo "Go module already published."
else
source_dir="$(mktemp -d)"
git archive "v${version}" | tar -x -C "$source_dir"
gcloud artifacts go upload \
--project="${{ vars.AR_PROJECT }}" \
--location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_GO_REPO }}" \
--module-path=github.com/operatorstack/yield \
--version="v${version}" \
--source="$source_dir"
fi
- name: Configure npm publishing
run: |
gcloud artifacts print-settings npm \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_NPM_REPO }}" --scope=@operatorstack > "$RUNNER_TEMP/yield.npmrc"
npx -y google-artifactregistry-auth "$RUNNER_TEMP/yield.npmrc"
- name: Publish TypeScript package
env:
VERSION: ${{ steps.version.outputs.version }}
NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/yield.npmrc
run: |
set -euo pipefail
for directory in dist/packages/npm/darwin-amd64 dist/packages/npm/darwin-arm64 dist/packages/npm/linux-amd64 dist/packages/npm/linux-arm64 dist/packages/npm/windows-amd64 dist/packages/npm/windows-arm64; do
package="$(node -p "require('./${directory}/package.json').name")"
if ! gcloud artifacts versions describe "$VERSION" \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_NPM_REPO }}" --package="$package" >/dev/null 2>&1; then
npm publish "$directory"
fi
done
if ! gcloud artifacts versions describe "$VERSION" \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_NPM_REPO }}" --package=@operatorstack/yield >/dev/null 2>&1; then
npm publish dist/packages/npm/yield
fi
- name: Build and publish Python wheels
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
python -m pip install --quiet build twine keyrings.google-artifactregistry-auth
mkdir -p dist/python-wheels
for directory in dist/packages/python/*; do
python -m build --wheel --outdir "$GITHUB_WORKSPACE/dist/python-wheels" "$directory"
done
existing="$(gcloud artifacts files list \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_PYTHON_REPO }}" --package=yieldskill --version="$VERSION" \
--format='value(name)' 2>/dev/null || true)"
for wheel in dist/python-wheels/*; do
filename="$(basename "$wheel")"
if ! grep -Fq "$filename" <<<"$existing"; then
twine upload \
--repository-url "https://${{ vars.AR_LOCATION }}-python.pkg.dev/${{ vars.AR_PROJECT }}/${{ vars.AR_PYTHON_REPO }}/" \
"$wheel"
existing="${existing}"$'\n'"${filename}"
fi
done
- name: Publish Rust runtime crates
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist/rust-runtime-publish
for directory in dist/packages/rust/runtime/*; do
name="$(sed -n 's/^name = "\(.*\)"/\1/p' "$directory/Cargo.toml")"
cargo package --manifest-path "$directory/Cargo.toml" --allow-dirty --no-verify
crate="$directory/target/package/${name}-${VERSION}.crate"
checksum="$(sha256sum "$crate" | cut -d' ' -f1)"
curl -fsSL "https://get.operatorstack.systems/cargo/index/$(printf '%s' "$name" | cut -c1-2)/$(printf '%s' "$name" | cut -c3-4)/${name}" \
-o "dist/rust-runtime-publish/${name}-index.json" || true
node packaging/cargo-index.mjs --name "$name" --version "$VERSION" --checksum "$checksum" \
--output "dist/rust-runtime-publish/${name}-index.json"
cp "$crate" dist/rust-runtime-publish/
done
gcloud artifacts generic upload \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_GENERIC_REPO }}" --package=yield-rust --version="$VERSION" \
--source-directory=dist/rust-runtime-publish --skip-existing
- name: Publish Rust public crate
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
mkdir -p "$HOME/.cargo" dist/rust-public-publish
printf '[registries.operatorstack]\nindex = "sparse+https://get.operatorstack.systems/cargo/index/"\n' > "$HOME/.cargo/config.toml"
directory=dist/packages/rust/yieldskill
rm -f "$directory/Cargo.lock"
cargo generate-lockfile --manifest-path "$directory/Cargo.toml"
cargo package --manifest-path "$directory/Cargo.toml" --registry operatorstack --allow-dirty
crate="$directory/target/package/yieldskill-${VERSION}.crate"
checksum="$(sha256sum "$crate" | cut -d' ' -f1)"
curl -fsSL https://get.operatorstack.systems/cargo/index/yi/el/yieldskill \
-o dist/rust-public-publish/yieldskill-index.json || true
node packaging/cargo-index.mjs --name yieldskill --version "$VERSION" --checksum "$checksum" \
--output dist/rust-public-publish/yieldskill-index.json
cp "$crate" dist/rust-public-publish/
gcloud artifacts generic upload \
--project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \
--repository="${{ vars.AR_GENERIC_REPO }}" --package=yield-rust --version="$VERSION" \
--source-directory=dist/rust-public-publish --skip-existing
- name: Verify complete package history
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
versions="$(git tag --list 'v[0-9]*' --sort=v:refname \
| sed 's/^v//' \
| awk -F. '$1 > 0 || $2 > 1 || ($2 == 1 && $3 >= 23)' \
| awk -v current="$VERSION" '{ print; if ($0 == current) exit }' \
| paste -sd, -)"
test -n "$versions"
for attempt in {1..12}; do
if node packaging/verify-registry-history.mjs --versions "$versions"; then
exit 0
fi
if [[ "$attempt" == 12 ]]; then
echo "Package history was still incomplete after 12 checks." >&2
exit 1
fi
sleep 10
done
install-journeys:
name: Install journeys (${{ matrix.target }})
needs: publish
strategy:
fail-fast: false
matrix:
include:
- target: linux-amd64
runner: ubuntu-24.04
- target: linux-arm64
runner: ubuntu-24.04-arm
- target: darwin-amd64
runner: macos-15-intel
- target: darwin-arm64
runner: macos-15
- target: windows-amd64
runner: windows-2025
- target: windows-arm64
runner: windows-11-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: actions/setup-node@v4
with:
node-version: "24"
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: dtolnay/rust-toolchain@stable
- name: Install and test all language packages
shell: bash
env:
VERSION: ${{ needs.publish.outputs.version }}
PREVIOUS_VERSION: ${{ needs.publish.outputs.previous_version }}
GOPROXY: https://get.operatorstack.systems/go,direct
run: |
set -euo pipefail
workspace="$RUNNER_TEMP/yield-install"
mkdir -p "$workspace/typescript" "$workspace/python" "$workspace/go" "$workspace/rust"
python_cmd=python
if ! command -v "$python_cmd" >/dev/null 2>&1; then python_cmd=python3; fi
finish_starter() {
FILE="$1" OLD="$2" NEW="$3" "$python_cmd" - <<'PY'
import os
from pathlib import Path
path = Path(os.environ["FILE"])
source = path.read_text()
old = os.environ["OLD"]
if old not in source:
raise SystemExit(f"starter line not found in {path}")
path.write_text(source.replace(old, os.environ["NEW"], 1))
PY
}
expect_incomplete_starter() {
if "$@" > "$RUNNER_TEMP/starter-test.log" 2>&1; then
echo "starter workflow unexpectedly passed" >&2
exit 1
fi
grep -F "replace the starter workflow and fixture before testing" "$RUNNER_TEMP/starter-test.log"
}
cd "$workspace/typescript"
git init -q
npm init -y >/dev/null
npm install --save-exact "@operatorstack/yield@${VERSION}" --registry=https://get.operatorstack.systems/npm/
npm exec -- yskill --version | grep -F "yskill ${VERSION}"
npm exec -- yskill init skill --language typescript --description "Run this workflow when checking the installed TypeScript package."
expect_incomplete_starter npm exec -- yskill doctor skill --test
finish_starter skill/main.ts \
' ctx.blocked("replace the starter workflow and fixture before testing")' \
' return { installed: true }'
npm exec -- yskill doctor skill --test
npm exec -- yskill register skill --agent cursor,codex,claude-code
npm exec -- yskill doctor skill --agent cursor,codex,claude-code
cd "$workspace/python"
git init -q
"$python_cmd" -m venv .venv
if [[ "$RUNNER_OS" == "Windows" ]]; then python_bin=.venv/Scripts/python; else python_bin=.venv/bin/python; fi
"$python_bin" -m pip install "yieldskill==${VERSION}" --index-url https://get.operatorstack.systems/pip/simple/
"$python_bin" -m yieldskill --version | grep -F "yskill ${VERSION}"
"$python_bin" -m yieldskill init skill --language python --description "Run this workflow when checking the installed Python package."
expect_incomplete_starter "$python_bin" -m yieldskill doctor skill --test
finish_starter skill/main.py \
' ctx.blocked("replace the starter workflow and fixture before testing")' \
$' python = ctx.run_command("venv-python", "python --version", 30)\n ctx.require(python.exit_code == 0, "the selected Python environment is on PATH", python)\n tool = ctx.run_command("venv-tool", "pip --version", 30)\n ctx.require(tool.exit_code == 0 and ".venv" in tool.stdout.lower(), "environment-local console scripts are on PATH", tool)\n return {"installed": True}'
"$python_bin" -m yieldskill doctor skill --test
"$python_bin" -m yieldskill register skill --agent cursor,codex,claude-code
"$python_bin" -m yieldskill doctor skill --agent cursor,codex,claude-code
cd "$workspace/go"
mkdir -p .yield/bin
GOBIN="$PWD/.yield/bin" go install "github.com/operatorstack/yield/cmd/yskill@v${VERSION}"
if [[ "$RUNNER_OS" == "Windows" ]]; then go_cli=.yield/bin/yskill.exe; go_launcher='.\.yield\bin\yskill.exe'; else go_cli=.yield/bin/yskill; go_launcher=.yield/bin/yskill; fi
"$go_cli" --version | grep -F "yskill ${VERSION}"
"$go_cli" init skill --language go --description "Run this workflow when checking the installed Go package." | tee "$RUNNER_TEMP/go-init.log"
grep -F "$go_launcher doctor 'skill' --test" "$RUNNER_TEMP/go-init.log"
grep -F "$go_launcher run 'skill'" skill/SKILL.md
expect_incomplete_starter "$go_cli" doctor skill --test
finish_starter skill/main.go \
'return yield.Outcome{}, ctx.Blocked("replace the starter workflow and fixture before testing")' \
'return ctx.Complete(map[string]any{"installed": true})'
"$go_cli" doctor skill --test
"$go_cli" register skill --agent cursor,codex,claude-code
"$go_cli" doctor skill --agent cursor,codex,claude-code
cd "$workspace/rust"
cargo install "yieldskill@${VERSION}" --index sparse+https://get.operatorstack.systems/cargo/index/ --locked --root .yield
if [[ "$RUNNER_OS" == "Windows" ]]; then rust_cli=.yield/bin/yskill.exe; rust_launcher='.\.yield\bin\yskill.exe'; else rust_cli=.yield/bin/yskill; rust_launcher=.yield/bin/yskill; fi
"$rust_cli" --version | grep -F "yskill ${VERSION}"
"$rust_cli" init skill --language rust --description "Run this workflow when checking the installed Rust package." | tee "$RUNNER_TEMP/rust-init.log"
grep -F "$rust_launcher doctor 'skill' --test" "$RUNNER_TEMP/rust-init.log"
grep -F "$rust_launcher run 'skill'" skill/SKILL.md
expect_incomplete_starter "$rust_cli" doctor skill --test
finish_starter skill/src/main.rs \
' Err(ctx.blocked("replace the starter workflow and fixture before testing"))' \
' Ok(json!({"installed": true}))'
"$rust_cli" doctor skill --test
"$rust_cli" register skill --agent cursor,codex,claude-code
"$rust_cli" doctor skill --agent cursor,codex,claude-code
if [[ -n "$PREVIOUS_VERSION" ]]; then
cd "$workspace"
mkdir -p previous/typescript previous/python previous/go previous/rust
cd previous/typescript
npm init -y >/dev/null
npm install --save-exact "@operatorstack/yield@${PREVIOUS_VERSION}" --registry=https://get.operatorstack.systems/npm/
npm exec -- yskill --version | grep -F "yskill ${PREVIOUS_VERSION}"
cd "$workspace/previous/python"
"$python_cmd" -m venv .venv
if [[ "$RUNNER_OS" == "Windows" ]]; then previous_python=.venv/Scripts/python; else previous_python=.venv/bin/python; fi
"$previous_python" -m pip install "yieldskill==${PREVIOUS_VERSION}" --index-url https://get.operatorstack.systems/pip/simple/
"$previous_python" -m yieldskill --version | grep -F "yskill ${PREVIOUS_VERSION}"
cd "$workspace/previous/go"
mkdir -p .yield/bin
GOBIN="$PWD/.yield/bin" go install "github.com/operatorstack/yield/cmd/yskill@v${PREVIOUS_VERSION}"
if [[ "$RUNNER_OS" == "Windows" ]]; then previous_go=.yield/bin/yskill.exe; else previous_go=.yield/bin/yskill; fi
"$previous_go" --version | grep -F "yskill ${PREVIOUS_VERSION}"
cd "$workspace/previous/rust"
cargo install "yieldskill@${PREVIOUS_VERSION}" --index sparse+https://get.operatorstack.systems/cargo/index/ --locked --root .yield
if [[ "$RUNNER_OS" == "Windows" ]]; then previous_rust=.yield/bin/yskill.exe; else previous_rust=.yield/bin/yskill; fi
"$previous_rust" --version | grep -F "yskill ${PREVIOUS_VERSION}"
fi
finalize-release:
name: Publish verified release receipt
needs: [publish, install-journeys]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Attach package contract and publish the draft
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.publish.outputs.version }}
PREVIOUS_VERSION: ${{ needs.publish.outputs.previous_version }}
SOURCE_SHA: ${{ needs.publish.outputs.source_sha }}
run: |
set -euo pipefail
if ! gh release view "v${VERSION}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "v${VERSION}" \
--repo "$GITHUB_REPOSITORY" \
--title "v${VERSION}" \
--generate-notes \
--draft \
--verify-tag
fi
jq -n \
--arg version "$VERSION" \
--arg previous_version "$PREVIOUS_VERSION" \
--arg source_sha "$SOURCE_SHA" \
--arg repository "$GITHUB_REPOSITORY" \
'{
schema_version: 1,
version: $version,
previous_version: $previous_version,
source_sha: $source_sha,
repository: $repository,
languages: ["typescript", "python", "go", "rust"],
targets: ["linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64", "windows-arm64"],
package_history_from: "0.1.23",
current_and_previous_install_journeys: true,
package_history_complete: true
}' > yield-package-contract.json
gh release upload "v${VERSION}" yield-package-contract.json --repo "$GITHUB_REPOSITORY" --clobber
gh release edit "v${VERSION}" --repo "$GITHUB_REPOSITORY" --draft=false --latest