Refine optional helper approval experience (#67) #59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish Yield packages | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: Existing stable tag version without the leading v | |
| type: string | |
| required: true | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: packages-${{ github.event_name == 'push' && 'canary' || 'stable' }} | |
| cancel-in-progress: false | |
| jobs: | |
| resolve: | |
| if: github.repository == 'operatorstack/yield' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| publish: ${{ steps.release.outputs.publish }} | |
| channel: ${{ steps.release.outputs.channel }} | |
| version: ${{ steps.release.outputs.version }} | |
| dist_tag: ${{ steps.release.outputs.dist_tag }} | |
| source_sha: ${{ steps.release.outputs.source_sha }} | |
| source_epoch: ${{ steps.release.outputs.source_epoch }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.sha }} | |
| - id: release | |
| name: Resolve immutable source, version, and channel | |
| env: | |
| REQUESTED_VERSION: ${{ inputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| source_sha="$(git rev-parse HEAD)" | |
| if [[ "$GITHUB_EVENT_NAME" == push ]]; then | |
| committed_at="$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S HEAD)" | |
| version="0.0.0-canary.${committed_at}.$(git rev-parse --short=12 HEAD)" | |
| channel=canary | |
| dist_tag=canary | |
| else | |
| version="$REQUESTED_VERSION" | |
| tag="v${version}" | |
| [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] | |
| test "$(git rev-list -n 1 "v${version}")" = "$source_sha" | |
| channel=stable | |
| dist_tag=latest | |
| fi | |
| echo "publish=true" >> "$GITHUB_OUTPUT" | |
| echo "channel=$channel" >> "$GITHUB_OUTPUT" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" | |
| echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" | |
| echo "source_epoch=$(git show -s --format=%ct HEAD)" >> "$GITHUB_OUTPUT" | |
| build: | |
| needs: resolve | |
| if: needs.resolve.outputs.publish == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| ref: ${{ needs.resolve.outputs.source_sha }} | |
| - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| with: | |
| node-version: "24" | |
| package-manager-cache: false | |
| - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Verify source | |
| run: | | |
| go test ./... | |
| node --test packaging/*.test.mjs sdk/typescript/bin/*.test.mjs | |
| python -m unittest discover -s sdk/python -p 'test_*.py' | |
| - name: Build immutable runtimes | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist/bin | |
| for spec in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do | |
| goos="${spec%/*}" | |
| goarch="${spec#*/}" | |
| 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 | |
| node packaging/assemble.mjs --version "$VERSION" --binaries dist/bin --output dist/packages | |
| - name: Build Python wheels | |
| if: needs.resolve.outputs.channel == 'stable' | |
| env: | |
| SOURCE_DATE_EPOCH: ${{ needs.resolve.outputs.source_epoch }} | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python -m pip install --disable-pip-version-check build==1.3.0 setuptools==80.9.0 wheel==0.45.1 | |
| mkdir -p dist/pypi | |
| for directory in dist/packages/python/*; do | |
| python -m build --wheel --no-isolation --outdir "$GITHUB_WORKSPACE/dist/pypi" "$directory" | |
| done | |
| node packaging/pypi-release.mjs inspect --version "$VERSION" --dist dist/pypi | |
| - name: Build Rust crates | |
| if: needs.resolve.outputs.channel == 'stable' | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| chmod 0644 dist/packages/rust/runtime/*/runtime/* | |
| node packaging/crates-release.mjs inspect --version "$VERSION" --rust dist/packages/rust | |
| mkdir -p dist/crates | |
| for directory in dist/packages/rust/runtime/*; do | |
| cargo package --manifest-path "$directory/Cargo.toml" | |
| name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" | |
| cp "$directory/target/package/${name}-${VERSION}.crate" dist/crates/ | |
| done | |
| test "$(find dist/crates -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ')" = 6 | |
| - name: Pack immutable npm release unit | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| node packaging/npm-release.mjs --source dist/packages/npm --output dist/release-unit/npm --source-sha "${{ needs.resolve.outputs.source_sha }}" | |
| - name: Assemble immutable release unit | |
| env: | |
| CHANNEL: ${{ needs.resolve.outputs.channel }} | |
| run: | | |
| mkdir -p dist/release-unit | |
| cp dist/packages/SHA256SUMS.json dist/release-unit/ | |
| if [[ "$CHANNEL" == stable ]]; then | |
| cp -R dist/pypi dist/release-unit/pypi | |
| cp -R dist/packages/rust dist/release-unit/rust | |
| cp -R dist/crates dist/release-unit/crates | |
| fi | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} | |
| path: dist/release-unit/ | |
| if-no-files-found: error | |
| npm: | |
| needs: [resolve, build] | |
| runs-on: ubuntu-latest | |
| environment: ${{ needs.resolve.outputs.channel == 'stable' && 'npm-production' || 'npm-canary' }} | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 | |
| with: | |
| name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} | |
| path: dist/release-unit | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| with: | |
| node-version: "24" | |
| registry-url: https://registry.npmjs.org | |
| package-manager-cache: false | |
| - name: Pin npm trusted-publishing client | |
| run: npm install --global npm@12.0.2 | |
| - name: Publish platform runtimes | |
| env: | |
| DIST_TAG: ${{ needs.resolve.outputs.dist_tag }} | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| jq -c '.archives[] | select(.name != "@operatorstack/yield" and .name != "@operatorstack/create-yield")' dist/release-unit/npm/npm-release.json | while read -r archive; do | |
| package="$(jq -r '.name' <<< "$archive")" | |
| file="$(jq -r '.file' <<< "$archive")" | |
| if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then | |
| echo "${package}@${VERSION} already exists" | |
| else | |
| npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" | |
| fi | |
| done | |
| - name: Publish SDK and CLI | |
| env: | |
| DIST_TAG: ${{ needs.resolve.outputs.dist_tag }} | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if npm view "@operatorstack/yield@${VERSION}" version >/dev/null 2>&1; then | |
| echo "@operatorstack/yield@${VERSION} already exists" | |
| else | |
| file="$(jq -r '.archives[] | select(.name == "@operatorstack/yield") | .file' dist/release-unit/npm/npm-release.json)" | |
| npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" | |
| fi | |
| - name: Publish npm initializer | |
| env: | |
| DIST_TAG: ${{ needs.resolve.outputs.dist_tag }} | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if npm view "@operatorstack/create-yield@${VERSION}" version >/dev/null 2>&1; then | |
| echo "@operatorstack/create-yield@${VERSION} already exists" | |
| else | |
| file="$(jq -r '.archives[] | select(.name == "@operatorstack/create-yield") | .file' dist/release-unit/npm/npm-release.json)" | |
| if [[ -n "${NPM_TOKEN:-}" ]]; then | |
| NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" | |
| else | |
| npm publish "dist/release-unit/npm/${file}" --tag "$DIST_TAG" | |
| fi | |
| fi | |
| - name: Verify complete npm release unit | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| jq -r '.archives[].name' dist/release-unit/npm/npm-release.json | while read -r package; do | |
| for attempt in {1..12}; do | |
| if [[ "$(npm view "${package}@${VERSION}" version 2>/dev/null || true)" == "$VERSION" ]]; then break; fi | |
| test "$attempt" -lt 12 | |
| sleep 10 | |
| done | |
| done | |
| - name: Smoke test npm initializer | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| root="$RUNNER_TEMP/create-yield-smoke" | |
| mkdir -p "$root" | |
| npm create "@operatorstack/yield@${VERSION}" -- \ | |
| --root "$root" --agent codex --dry-run | |
| test ! -e "$root/skills" | |
| pypi: | |
| needs: [resolve, build] | |
| if: needs.resolve.outputs.channel == 'stable' | |
| runs-on: ubuntu-latest | |
| environment: pypi-production | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ needs.resolve.outputs.source_sha }} | |
| - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 | |
| with: | |
| name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} | |
| path: dist/release-unit | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| with: | |
| node-version: "24" | |
| package-manager-cache: false | |
| - id: prepare | |
| name: Refuse PyPI drift and select missing wheels | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| run: >- | |
| node packaging/pypi-release.mjs prepare | |
| --version "$VERSION" | |
| --dist dist/release-unit/pypi | |
| --upload dist/pypi-upload | |
| --output "$GITHUB_OUTPUT" | |
| - name: Publish Python wheels with trusted publishing | |
| if: steps.prepare.outputs.publish == 'true' | |
| uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 | |
| with: | |
| packages-dir: dist/pypi-upload | |
| attestations: true | |
| verbose: true | |
| - name: Verify complete PyPI release unit | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| run: >- | |
| node packaging/pypi-release.mjs verify | |
| --version "$VERSION" | |
| --dist dist/release-unit/pypi | |
| --attempts 12 | |
| --delay-ms 10000 | |
| crates: | |
| needs: [resolve, build] | |
| if: needs.resolve.outputs.channel == 'stable' | |
| runs-on: ubuntu-latest | |
| environment: crates-production | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ needs.resolve.outputs.source_sha }} | |
| - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 | |
| with: | |
| name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} | |
| path: dist/release-unit | |
| - id: auth | |
| name: Request a short-lived crates.io token | |
| uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 | |
| - name: Publish complete Rust release unit | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| publish_if_missing() { | |
| local directory="$1" | |
| local name | |
| name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" | |
| local archive="dist/release-unit/crates/${name}-${VERSION}.crate" | |
| local state | |
| state="$(node packaging/crates-release.mjs status --version "$VERSION" --name "$name" --archive "$archive")" | |
| if [[ "$state" == matched ]]; then | |
| echo "${name}@${VERSION} already exists with the release-unit checksum" | |
| else | |
| test "$state" = missing | |
| (cd "$directory" && cargo publish) | |
| fi | |
| } | |
| for directory in dist/release-unit/rust/runtime/*; do | |
| publish_if_missing "$directory" | |
| done | |
| for attempt in {1..18}; do | |
| missing=0 | |
| for directory in dist/release-unit/rust/runtime/*; do | |
| name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)" | |
| archive="dist/release-unit/crates/${name}-${VERSION}.crate" | |
| if [[ "$(node packaging/crates-release.mjs status --version "$VERSION" --name "$name" --archive "$archive")" != matched ]]; then | |
| missing=1 | |
| fi | |
| done | |
| if [[ "$missing" == 0 ]]; then break; fi | |
| test "$attempt" -lt 18 | |
| sleep 10 | |
| done | |
| mkdir -p dist/crates-receipt | |
| cp dist/release-unit/crates/*.crate dist/crates-receipt/ | |
| cargo package --manifest-path dist/release-unit/rust/yieldskill/Cargo.toml | |
| cp "dist/release-unit/rust/yieldskill/target/package/yieldskill-${VERSION}.crate" dist/crates-receipt/ | |
| main_state="$(node packaging/crates-release.mjs status \ | |
| --version "$VERSION" \ | |
| --name yieldskill \ | |
| --archive "dist/crates-receipt/yieldskill-${VERSION}.crate")" | |
| if [[ "$main_state" == matched ]]; then | |
| echo "yieldskill@${VERSION} already exists with the publisher receipt checksum" | |
| else | |
| test "$main_state" = missing | |
| cargo publish --manifest-path dist/release-unit/rust/yieldskill/Cargo.toml | |
| fi | |
| node packaging/crates-release.mjs verify \ | |
| --version "$VERSION" \ | |
| --archives dist/crates-receipt \ | |
| --attempts 18 \ | |
| --delay-ms 10000 | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} | |
| path: dist/crates-receipt/ | |
| if-no-files-found: error | |
| selfhost-canary: | |
| name: Release skill against exact canary | |
| needs: [resolve, npm] | |
| if: needs.resolve.outputs.channel == 'canary' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ needs.resolve.outputs.source_sha }} | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| with: | |
| node-version: "24" | |
| package-manager-cache: false | |
| - name: Install the exact published canary without changing repository state | |
| env: | |
| VERSION: ${{ needs.resolve.outputs.version }} | |
| run: | | |
| npm ci --ignore-scripts | |
| npm install --no-save --package-lock=false --ignore-scripts "@operatorstack/yield@${VERSION}" | |
| test "$(npm exec -- yskill version | awk '{print $2}')" = "$VERSION" | |
| - name: Run the release skill contract against the canary SDK | |
| run: | | |
| npm run test:selfhost | |
| journal="$RUNNER_TEMP/release-yield-journal.json" | |
| output="$RUNNER_TEMP/release-yield-output.json" | |
| printf '%s\n' '{"run_id":"canary-smoke","skill":{"name":"release-yield","digest":"sha256:canary"}}' > "$journal" | |
| YIELD_JOURNAL="$journal" node skills/release-yield/main.ts > "$output" | |
| node -e ' | |
| const output = require(process.argv[1]); | |
| if (output.type !== "request" || output.envelope.request.id !== "select-mode") process.exit(1); | |
| ' "$output" |