diff --git a/.github/workflows/core-drift.yml b/.github/workflows/core-drift.yml new file mode 100644 index 0000000..7630059 --- /dev/null +++ b/.github/workflows/core-drift.yml @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 + +# Nightly comparison of core main with the pinned core tag; keeps the single +# "Core main drift" issue current (scripts/core-sync/drift-check.sh). +name: core-drift + +on: + schedule: + - cron: "41 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: core-drift + cancel-in-progress: false + +jobs: + drift: + name: drift + # The bot secrets live in the sdk-bot environment, which only main can + # deploy to, so a pushed branch can't mint a bot token. + environment: sdk-bot + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.SDK_BOT_CLIENT_ID }} + private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }} + permission-issues: write + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + + # Exit 1 with a report means drift; the issue records it. No report + # means the check itself broke, which the failure step records. + - name: Check drift against core main + run: | + scripts/core-sync/drift-check.sh --out drift-report.md || true + test -s drift-report.md + cat drift-report.md >>"$GITHUB_STEP_SUMMARY" + + - name: Update the drift issue + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: scripts/core-sync/update-drift-issue.sh drift-report.md + + - name: Report failure + if: failure() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: scripts/core-sync/report-failure.sh core-drift "$RUN_URL" diff --git a/.github/workflows/core-watch.yml b/.github/workflows/core-watch.yml new file mode 100644 index 0000000..f9cab80 --- /dev/null +++ b/.github/workflows/core-watch.yml @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 + +# Opens one update proposal per new core release tag +# (scripts/core-sync/open-bump-pr.sh). +name: core-watch + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + inputs: + tag: + description: Core tag to propose (vX.Y.Z or vX.Y.Z-pre.N); empty proposes every new tag + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: core-watch + cancel-in-progress: false + +jobs: + watch: + name: watch + # The bot secrets live in the sdk-bot environment, which only main can + # deploy to, so a pushed branch can't mint a bot token. + environment: sdk-bot + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.SDK_BOT_CLIENT_ID }} + private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + permission-issues: write + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + persist-credentials: true + + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + + - name: Set the bot identity + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + set -euo pipefail + id=$(gh api "users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${id}+${APP_SLUG}[bot]@users.noreply.github.com" + + - name: Propose new core tags + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [ -n "$INPUT_TAG" ]; then + tags=$INPUT_TAG + else + tags=$(scripts/core-sync/core-tags.sh) + fi + if [ -z "$tags" ]; then + echo "No core tags newer than $(mise run --quiet core:version)." + exit 0 + fi + for t in $tags; do + scripts/core-sync/open-bump-pr.sh "$t" + done + + - name: Report failure + if: failure() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: scripts/core-sync/report-failure.sh core-watch "$RUN_URL" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f99646..ed533e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,16 @@ Shared test fixtures come from core's `sdk/conformance/` and are copied into `tests/fixtures/`. `mise run conformance:check` compares them with core at the pinned ref. +## Core bumps and drift + +Automation watches core for new tags and opens a pull request that moves the +pin, labeled `core-bump` plus `stable` or `prerelease`. If a bump needs SDK +changes, push them to the same branch. + +A nightly job checks core's `main` for breaking proto changes and RPCs the SDK +doesn't cover yet, and keeps one issue labeled `core-drift` up to date. See +[scripts/README.md](scripts/README.md) for how both work. + ## Releases SDK releases follow core releases: SDK `X.Y.Z` is built from core `vX.Y.Z`, and diff --git a/README.md b/README.md index 93ff9f9..f858988 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,21 @@ reads them, and `grpcio-tools` generates the bindings: pinned protos, and `mise run conformance:check` does the same for the vendored test fixtures and `tests/e2e/Dockerfile.workload`. +## Keeping up with core + +`core-watch.yml` checks core's release tags hourly and opens a PR, +`chore(core): track `, that moves the pin, regenerates the bindings and +refreshes the files copied from core. It does that for each new stable tag and +for the newest pre-release. `core-drift.yml` compares core `main` with the pin +nightly and keeps one `Core main drift` issue up to date. Both run scripts you +can also run locally; see [scripts/README.md](scripts/README.md). + +```shell +mise run core:tags # core tags newer than the pin +mise run core:bump -- v0.1.8 --dry-run # build the bump PR locally +mise run core:drift -- --out drift-report.md # compare core main with the pin +``` + ## E2E tests `tests/e2e` runs against a real gateway; `mise run test` skips it. CI runs it diff --git a/mise.toml b/mise.toml index 0a99ef1..44d7869 100644 --- a/mise.toml +++ b/mise.toml @@ -65,17 +65,18 @@ fi """ [tasks."conformance:check"] -description = "Fail if vendored fixtures or the E2E workload Dockerfile differ from core at the pinned ref" +description = "Fail if a file copied from core (scripts/vendored-core-files.txt) differs from core at the pinned ref" run = """ #!/usr/bin/env bash set -euo pipefail core=$(mktemp -d) trap 'rm -rf "$core"' EXIT -scripts/fetch-core.sh "$OPENSHELL_CORE_REF" "$core" sdk/conformance e2e/python/Dockerfile.workload -for fixture in oauth-client-credentials.json oauth-client-credentials.json.license; do - diff -u "$core/sdk/conformance/$fixture" "tests/fixtures/$fixture" -done -diff -u "$core/e2e/python/Dockerfile.workload" tests/e2e/Dockerfile.workload +files=$(grep -v '^#' scripts/vendored-core-files.txt) +# shellcheck disable=SC2046 # one argument per core path +scripts/fetch-core.sh "$OPENSHELL_CORE_REF" "$core" $(awk '{ print $1 }' <<<"$files") +while read -r src dst; do + diff -u "$core/$src" "$dst" +done <<<"$files" """ [tasks.lint] @@ -143,3 +144,15 @@ done [tasks.ci] description = "Run the full CI pipeline" depends = ["check", "test", "build"] + +[tasks."core:tags"] +description = "List core release tags newer than the pin (newest pre-release only, --all for every tag)" +run = "scripts/core-sync/core-tags.sh" + +[tasks."core:bump"] +description = "Open the update proposal for a core tag: mise run core:bump -- [--dry-run]" +run = "scripts/core-sync/open-bump-pr.sh" + +[tasks."core:drift"] +description = "Compare core main with the pin: mise run core:drift -- --out " +run = "scripts/core-sync/drift-check.sh" diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f3a5a0b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,44 @@ +# Scripts + +The workflows under `.github/workflows/` run these scripts, most through a mise task, so each job can be run locally first. Scripts that write to GitHub take `--dry-run` and print what they would do instead. + +## `core-sync/`: keeping up with core + +Move the core pin (`OPENSHELL_CORE_REF` in `mise.toml`) to new core releases, and watch core `main` for changes that will break the SDK. + +The workflows use a GitHub App token from the `sdk-bot` environment (secrets `SDK_BOT_CLIENT_ID` and `SDK_BOT_PRIVATE_KEY`), which only `main` can deploy to. They never use `GITHUB_TOKEN`, because pushes and PRs made with it don't trigger CI. + +| File | mise task | Purpose | +|------|-----------|---------| +| `core-tags.sh [--all]` | `core:tags` | List NVIDIA/OpenShell release tags newer than the pin: every stable tag, plus the newest pre-release when it is newer than all of them. | +| `open-bump-pr.sh [--dry-run]` | `core:bump` | Open the update proposal for one core tag: branch `core/`, pin change, regenerated bindings, refreshed copies of core files, proto diff in the body, labels `core-bump` plus `stable` or `prerelease`. Closes older pre-release proposals. | +| `bump-pr-body.md` | | PR body template for `open-bump-pr.sh`. | +| `drift-check.sh --out ` | `core:drift` | Compare core `main` with the pin: `buf breaking`, regenerate and run the unit tests, list the RPCs the SDK doesn't call. Writes the markdown report. | +| `update-drift-issue.sh [--dry-run]` | | Create, update, or close the single `Core main drift` issue from a report. | +| `report-failure.sh [--dry-run]` | | Append a dated failure line to the drift issue. Called from `if: failure()` steps. | +| `drift-issue.sh` | | Shared helpers for the two drift issue scripts (sourced, not run). | + +| Workflow | Runs | +|----------|------| +| `core-watch.yml` (hourly) | `core-tags.sh`, then `open-bump-pr.sh` per tag | +| `core-drift.yml` (nightly) | `drift-check.sh`, then `update-drift-issue.sh` | + +Bump PRs run E2E against their own pin, because `e2e.yml` defaults to `OPENSHELL_CORE_REF` on the PR branch. + +## `e2e/`: the E2E gateway + +| File | Purpose | +|------|---------| +| `start-gateway.sh ` | Run the published gateway of a core version with the Docker driver for the E2E lane, and register it as `e2e`. | +| `stop-gateway.sh` | Stop it, collect its log, and remove its sandboxes. | + +`e2e.yml` (reusable, called from `ci.yml`) runs `start-gateway.sh`, `mise run e2e`, then `stop-gateway.sh`. + +## Build and checks + +| File | mise task | Purpose | +|------|-----------|---------| +| `generate_python_proto.py` | `proto:gen` | Generate `src/openshell/_proto` from `proto/`. | +| `verify-python-wheel.py` | `build` | Check the wheel's contents and version. | +| `vendored-core-files.txt` | `conformance:check` | Files copied from core. `conformance:check` compares them with core at the pin; `core-sync/open-bump-pr.sh` refreshes them. | +| `fetch-core.sh ...` | `conformance:check` | Copy files from core at a ref. | diff --git a/scripts/core-sync/bump-pr-body.md b/scripts/core-sync/bump-pr-body.md new file mode 100644 index 0000000..cba358c --- /dev/null +++ b/scripts/core-sync/bump-pr-body.md @@ -0,0 +1,16 @@ +Move the core pin from `{{OLD}}` to `{{NEW}}` ({{KIND}}). + +- Core changes: [{{OLD}}...{{NEW}}]({{COMPARE_URL}}) +- Regenerated bindings: {{GENERATED}} +- E2E runs against `{{IMAGE}}` +- Files copied from core: +{{FIXTURES}} + +## Proto changes + +{{PROTO_DIFF}} + +## Before merging + +- CI and E2E must pass. If the SDK needs changes for this core version, push them to this branch. +- Pre-release pins are not released; a newer pre-release proposal replaces this one. diff --git a/scripts/core-sync/core-tags.sh b/scripts/core-sync/core-tags.sh new file mode 100755 index 0000000..fbc4aa0 --- /dev/null +++ b/scripts/core-sync/core-tags.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +# core-tags.sh: print NVIDIA/OpenShell release tags newer than the pin, +# oldest first. +# +# Usage: core-tags.sh [--all] [--pin ] +# +# Release tags are vX.Y.Z and vX.Y.Z-pre.N; a pre-release sorts below its +# stable version. By default every newer stable tag is printed, plus the +# newest pre-release when it is newer than all of them: one proposal for the +# current release train instead of one per pre-release. --all prints every +# newer tag. --pin overrides the pin, OPENSHELL_CORE_REF in mise.toml. +set -euo pipefail + +repo=${CORE_REPO:-NVIDIA/OpenShell} +all=0 +pin= + +while [ $# -gt 0 ]; do + case $1 in + --all) all=1; shift ;; + --pin) pin=${2:?--pin needs a tag}; shift 2 ;; + *) echo "usage: $0 [--all] [--pin ]" >&2; exit 2 ;; + esac +done + +[ -n "$pin" ] || pin=$(sed -n 's/^OPENSHELL_CORE_REF = "\(.*\)"$/\1/p' mise.toml) +[ -n "$pin" ] || { echo "ERROR: no OPENSHELL_CORE_REF in mise.toml" >&2; exit 1; } + +# key : "major minor patch rank pre" with rank 1 for stable, 0 for pre. +key() { + awk -v t="$1" 'BEGIN { + s = t; sub(/^v/, "", s) + pre = ""; rank = 1 + if (index(s, "-pre.")) { pre = substr(s, index(s, "-pre.") + 5); s = substr(s, 1, index(s, "-pre.") - 1); rank = 0 } + split(s, v, ".") + printf "%d %d %d %d %d\n", v[1], v[2], v[3], rank, (pre == "" ? 0 : pre) + }' +} + +newer=$( + gh api --paginate "repos/$repo/git/matching-refs/tags/v" --jq '.[].ref' | + sed 's#^refs/tags/##' | + { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-pre\.[0-9]+)?$' || true; } | + while read -r t; do echo "$(key "$t") $t"; done | + awk -v p="$(key "$pin")" ' + BEGIN { split(p, k, " ") } + { + for (i = 1; i <= 5; i++) { + if ($i + 0 > k[i] + 0) { print; next } + if ($i + 0 < k[i] + 0) next + } + }' | + sort -n -k1,1 -k2,2 -k3,3 -k4,4 -k5,5 | + awk '{ print $6 }' +) + +[ -n "$newer" ] || exit 0 +if [ "$all" -eq 1 ]; then + echo "$newer" + exit 0 +fi + +# Stable tags, then the last tag if it is a pre-release (newer than every stable). +grep -v -- '-pre\.' <<<"$newer" || true +last=$(tail -1 <<<"$newer") +case $last in *-pre.*) echo "$last" ;; esac diff --git a/scripts/core-sync/drift-check.sh b/scripts/core-sync/drift-check.sh new file mode 100755 index 0000000..fc18689 --- /dev/null +++ b/scripts/core-sync/drift-check.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +# drift-check.sh: compare core main with the pinned core tag. +# +# Usage: drift-check.sh --out +# +# Run from the root of an sdk-python clone. Three checks: +# 1. buf breaking: core main against the pinned tag (FILE rules), for the +# proto files the SDK generates (what `mise run proto:fetch` exports) +# 2. regenerate and test: bindings from core main in a temporary +# worktree, then the unit tests +# 3. uncovered RPCs: methods in the regenerated *_pb2_grpc.py stubs that no +# SDK code under src/openshell calls (the bindings excluded); +# informational, it does not fail the check +# Writes a markdown report with the header +# "Pin: | Core main: | Result: pass|fail" and one section per +# check. Exits 0 when checks 1 and 2 pass, 1 otherwise. +set -euo pipefail + +core_url=${CORE_URL:-https://github.com/NVIDIA/OpenShell.git} +out= +while [ $# -gt 0 ]; do + case $1 in + --out) out=${2:?--out needs a file}; shift 2 ;; + *) echo "usage: $0 --out " >&2; exit 2 ;; + esac +done +[ -n "$out" ] || { echo "usage: $0 --out " >&2; exit 2; } +[ "$(git rev-parse --show-toplevel)" = "$(pwd -P)" ] || { + echo "ERROR: run this from the root of the sdk-python clone" >&2 + exit 1 +} +case $out in /*) ;; *) out="$(pwd)/$out" ;; esac + +pin_of() { sed -n 's/^OPENSHELL_CORE_REF = "\(.*\)"$/\1/p'; } +pin=$(pin_of &2; exit 1; } +main_sha=$(git ls-remote "$core_url" refs/heads/main | cut -f1) +[ -n "$main_sha" ] || { echo "ERROR: cannot resolve core main" >&2; exit 1; } + +work=$(mktemp -d) +cleanup() { git worktree remove --force "$work/wt" >/dev/null 2>&1 || true; rm -rf "$work"; } +trap cleanup EXIT +result=pass +fence() { printf '```text\n%s\n```\n' "$1"; } + +# 1. buf breaking for the files the SDK generates: the two roots of +# proto:fetch and their imports, as exported at the pin. +buf export "${core_url}#ref=${pin},depth=1,subdir=proto" \ + --path openshell.proto --path credential_driver.proto --output "$work/pinned" +files=$(cd "$work/pinned" && find . -name '*.proto' | sed 's#^\./##' | sort) +path_args=() +for f in $files; do path_args+=(--path "proto/$f"); done +# Core's root buf.yaml has two modules; this config keeps only proto/, so +# its imports resolve the same way buf export sees them. +if breaking=$(buf breaking "${core_url}#ref=${main_sha}" \ + --against "${core_url}#tag=${pin}" \ + --config '{"version":"v2","modules":[{"path":"proto"}],"breaking":{"use":["FILE"]}}' \ + "${path_args[@]}" 2>&1); then + breaking_section="No breaking changes in $(echo $files | sed 's/ /, /g') between \`$pin\` and main." +else + result=fail + breaking_section="Breaking changes between \`$pin\` and main: + +$(fence "$breaking")" +fi + +# 2. Regenerate from main and run the unit tests. +git worktree add --quiet --detach "$work/wt" HEAD +if build=$( + cd "$work/wt" && + sed -i.bak "s/^OPENSHELL_CORE_REF = \".*\"$/OPENSHELL_CORE_REF = \"$main_sha\"/" mise.toml && + rm -f mise.toml.bak && + mise trust --quiet mise.toml && + mise run --quiet proto:gen 2>&1 && + mise run --quiet test 2>&1 +); then + changed=$(git -C "$work/wt" diff --stat -- src/openshell/_proto | tail -1 | sed 's/^ *//') + build_section="Bindings from main pass the unit tests (${changed:-no generated files change})." +else + result=fail + build_section="Regenerating from main or testing against it fails: + +$(fence "$(tail -n 60 <<<"$build")")" +fi + +# 3. RPCs the SDK does not call yet. Full method names look like +# "/openshell.v1.OpenShell/CreateSandbox". +uncovered="" +if ls "$work/wt"/src/openshell/_proto/*_pb2_grpc.py >/dev/null 2>&1; then + methods=$(grep -ho "['\"]/[a-zA-Z0-9_.]*\.[A-Za-z0-9_]*/[A-Za-z0-9_]*['\"]" \ + "$work/wt"/src/openshell/_proto/*_pb2_grpc.py | tr -d "'\"" | sort -u) + for m in $methods; do + service=${m#/}; service=${service%%/*}; service=${service##*.} + method=${m##*/} + if ! grep -rqE "\.${method}\(" src/openshell --include='*.py' --exclude-dir=_proto; then + uncovered+="- ${service}.${method}"$'\n' + fi + done +fi +if [ -z "$uncovered" ]; then + uncovered_section="Every RPC in the regenerated stubs is called by the SDK." +else + uncovered_section="RPCs in core main that the SDK does not call yet ($(grep -c . <<<"$uncovered")): + +${uncovered%$'\n'}" +fi + +cat >"$out" < " lines of a body's failures section. +failure_lines() { + awk -v h="$failures_heading" ' + $0 == h { f = 1; next } + f && /^## / { f = 0 } + f && /^- / { print }' <<<"$1" +} + +# compose_body +compose_body() { + printf '%s\n\n%s\n\n' "$1" "$failures_heading" + if [ -n "$2" ]; then + printf '%s\n\n' "$2" + else + printf 'None.\n\n' + fi + printf '%s\n' "Automation appends a dated line here when core-watch or core-drift fails. Delete a line once it is handled; the issue closes when the drift check passes and no lines are left." +} diff --git a/scripts/core-sync/open-bump-pr.sh b/scripts/core-sync/open-bump-pr.sh new file mode 100755 index 0000000..c3a4afc --- /dev/null +++ b/scripts/core-sync/open-bump-pr.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +# open-bump-pr.sh: open the update proposal that moves the core pin to . +# +# Usage: open-bump-pr.sh [--dry-run] [--base ] +# +# Run from the root of an sdk-python clone. Builds branch core/ from +# origin/main (or --base) in a temporary worktree: pin change +# (OPENSHELL_CORE_REF in mise.toml), regenerated bindings, refreshed copies of +# core files (scripts/vendored-core-files.txt), one commit +# "chore(core): track ". Then pushes it and opens a PR labeled +# core-bump plus stable or prerelease, and closes open pre-release proposals +# for older tags. --dry-run does the local part, prints the PR body, and +# changes nothing on GitHub. +# +# Skips the tag when branch core/ exists on origin or any PR from it +# exists (merged or closed), so a closed proposal is never reopened. When the +# core tag no longer exists, closes an open proposal for it. +# Git identity and GH_TOKEN come from the environment (the App in CI). +set -euo pipefail + +core_repo=${CORE_REPO:-NVIDIA/OpenShell} +sdk_repo=${SDK_REPO:-ai-openshell/sdk-python} +image=ghcr.io/nvidia/openshell/gateway +diff_cap=400 + +tag= +dry=0 +base=origin/main +while [ $# -gt 0 ]; do + case $1 in + --dry-run) dry=1; shift ;; + --base) base=${2:?--base needs a ref}; shift 2 ;; + -*) echo "usage: $0 [--dry-run] [--base ]" >&2; exit 2 ;; + *) [ -z "$tag" ] || { echo "usage: $0 [--dry-run]" >&2; exit 2; }; tag=$1; shift ;; + esac +done +[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-pre\.[0-9]+)?$ ]] || { + echo "ERROR: '$tag' is not a core release tag (vX.Y.Z or vX.Y.Z-pre.N)" >&2 + exit 2 +} + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +body_tmpl="$here/bump-pr-body.md" +branch="core/$tag" +case $tag in *-pre.*) kind=prerelease ;; *) kind=stable ;; esac + +die() { echo "ERROR: $*" >&2; exit 1; } + +# older : true when release tag a sorts below b (vX.Y.Z-pre.N < vX.Y.Z). +older() { + awk -v a="$1" -v b="$2" ' + function key(t, s, pre, rank, v) { + s = t; sub(/^v/, "", s); pre = 0; rank = 1 + if (index(s, "-pre.")) { pre = substr(s, index(s, "-pre.") + 5) + 0; s = substr(s, 1, index(s, "-pre.") - 1); rank = 0 } + split(s, v, ".") + return sprintf("%06d%06d%06d%d%06d", v[1], v[2], v[3], rank, pre) + } + BEGIN { exit !(key(a) < key(b)) }' +} +run() { if [ "$dry" -eq 1 ]; then echo "dry-run: $*"; else "$@"; fi; } +pin_of() { sed -n 's/^OPENSHELL_CORE_REF = "\(.*\)"$/\1/p'; } + +[ "$(git rev-parse --show-toplevel)" = "$(pwd -P)" ] || die "run this from the root of the sdk-python clone" +[ -f "$body_tmpl" ] || die "$body_tmpl is missing" + +open_pr_for_branch() { + gh pr list -R "$sdk_repo" --head "$branch" --state open --json number --jq '.[0].number // empty' +} + +# The core tag may have been deleted (a withdrawn pre-release). Only a 404 +# means that; any other failure (rate limit, 5xx) stops the run. +if ! err=$(gh api "repos/$core_repo/git/ref/tags/$tag" --silent 2>&1); then + grep -q 'HTTP 404' <<<"$err" || die "looking up core tag $tag failed: $err" + n=$(open_pr_for_branch) + if [ -n "$n" ]; then + echo "Core tag $tag is gone; closing #$n." + run gh pr close "$n" -R "$sdk_repo" --delete-branch \ + --comment "Core tag \`$tag\` no longer exists in $core_repo, so this proposal is closed." + else + echo "Core tag $tag does not exist; nothing to do." + fi + exit 0 +fi + +if [ -n "$(git ls-remote --heads origin "$branch")" ]; then + echo "Branch $branch exists on origin; skipping $tag." + exit 0 +fi +# Any earlier proposal is final: merged, closed by a maintainer, or closed as +# superseded by a newer pre-release. Its branch may be gone, so ask for PRs. +prev=$(gh pr list -R "$sdk_repo" --head "$branch" --state all --json number,state \ + --jq '.[0] | select(.) | "#\(.number) (\(.state | ascii_downcase))"') +if [ -n "$prev" ]; then + echo "Proposal $prev exists for $tag already; skipping." + exit 0 +fi + +git fetch --quiet origin +git rev-parse --verify --quiet "$base^{commit}" >/dev/null || die "base $base is not a commit" +old=$(git show "$base:mise.toml" | pin_of) +[ -n "$old" ] || die "no OPENSHELL_CORE_REF in mise.toml on $base" +[ "$old" != "$tag" ] || { echo "The pin on $base is $tag already; skipping."; exit 0; } + +work=$(mktemp -d) +cleanup() { git worktree remove --force "$work/wt" >/dev/null 2>&1 || true; rm -rf "$work"; } +trap cleanup EXIT +git worktree add --quiet -b "$branch" "$work/wt" "$base" + +( + cd "$work/wt" + sed -i.bak "s/^OPENSHELL_CORE_REF = \".*\"$/OPENSHELL_CORE_REF = \"$tag\"/" mise.toml && rm -f mise.toml.bak + [ "$(pin_of &2; exit 1; } + mise trust --quiet mise.toml + log=$(mise run --quiet proto:gen 2>&1) || { echo "$log" >&2; exit 1; } + + # Refresh the copies of core files; one not in the tag yet is left as is. + : >"$work/fixtures.txt" + while read -r src local; do + status=$(curl -sS -o "$work/fixture" -w '%{http_code}' \ + "https://raw.githubusercontent.com/$core_repo/$tag/$src") + case $status in + 200) + if ! cmp -s "$work/fixture" "$local"; then + cp "$work/fixture" "$local" + echo "- \`$local\` updated from core \`$tag\`" >>"$work/fixtures.txt" + fi + ;; + 404) echo "- \`$local\` is not in core \`$tag\` yet, left unchanged" >>"$work/fixtures.txt" ;; + *) echo "ERROR: fetching $src at $tag returned HTTP $status" >&2; exit 1 ;; + esac + done < <(grep -v '^#' scripts/vendored-core-files.txt) + + # Keep the README's "currently vX.Y.Z" in step with stable pins. + if [ "$kind" = stable ]; then + sed -i.bak "s/currently \`${old}\`/currently \`${tag}\`/" README.md && rm -f README.md.bak + fi + + git add -A + git commit --quiet --signoff -m "chore(core): track $tag" -m "Move the core pin from $old to $tag and regenerate the bindings." +) + +# Proto diff between the two core tags. The compare API caps its file list at +# 300 entries, so fetch both tags shallow and blobless and diff locally; only +# the proto/ blobs are downloaded. +compare_url="https://github.com/$core_repo/compare/$old...$tag" +git init --quiet --bare "$work/core.git" +git -C "$work/core.git" fetch --quiet --depth=1 --filter=blob:none --no-tags \ + "https://github.com/$core_repo.git" "refs/tags/$old:refs/tags/$old" "refs/tags/$tag:refs/tags/$tag" +proto_diff=$(git -C "$work/core.git" diff "$old" "$tag" -- proto/) +if [ -z "$proto_diff" ]; then + proto_section="No changes under \`proto/\` between \`$old\` and \`$tag\`." +else + lines=$(wc -l <<<"$proto_diff" | tr -d ' ') + shown=$(head -n "$diff_cap" <<<"$proto_diff") + proto_section=$(printf '```diff\n%s\n```' "$shown") + if [ "$lines" -gt "$diff_cap" ]; then + proto_section=$(printf '%s\n\nShowing %s of %s lines; see the [full compare](%s).' "$proto_section" "$diff_cap" "$lines" "$compare_url") + fi +fi +generated=$(git diff --stat "$base" "$branch" -- src/openshell/_proto | tail -1 | sed "s/^ *//") +fixtures=$(cat "$work/fixtures.txt") + +body="$work/body.md" +OLD="$old" NEW="$tag" KIND="$kind" COMPARE_URL="$compare_url" IMAGE="$image:${tag#v}" \ +GENERATED="${generated:-no generated files changed}" FIXTURES="${fixtures:- - none changed}" \ +PROTO_DIFF="$proto_section" \ + awk '{ + while (match($0, /\{\{[A-Z_]+\}\}/)) { + key = substr($0, RSTART + 2, RLENGTH - 4) + $0 = substr($0, 1, RSTART - 1) ENVIRON[key] substr($0, RSTART + RLENGTH) + } + print + }' "$body_tmpl" >"$body" + +echo "Proposal for $tag ($kind), pin $old -> $tag:" +git log --format=' %h %an <%ae> %s' "$base..$branch" +git diff --stat "$base" "$branch" | tail -1 | sed 's/^/ /' + +if [ "$dry" -eq 1 ]; then + echo + echo "PR title: chore(core): track $tag" + echo "PR body:" + sed 's/^/ | /' "$body" + cleanup + git branch -D "$branch" >/dev/null + echo + echo "dry-run: nothing pushed; local branch $branch removed." + exit 0 +fi + +git push --quiet origin "$branch" +url=$(gh pr create -R "$sdk_repo" --base main --head "$branch" \ + --title "chore(core): track $tag" --body-file "$body" \ + --label core-bump --label "$kind") +echo "Opened $url" +new=${url##*/} + +# Close open pre-release proposals for older tags; this one supersedes them. +gh pr list -R "$sdk_repo" --label core-bump --label prerelease --state open \ + --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"' | + while read -r n head; do + other=${head#core/} + [ "$n" != "$new" ] && [ "$other" != "$tag" ] || continue + if older "$other" "$tag"; then + echo "Closing #$n ($other), superseded by #$new." + gh pr close "$n" -R "$sdk_repo" --delete-branch \ + --comment "Superseded by #$new, which tracks core \`$tag\`." + fi + done diff --git a/scripts/core-sync/report-failure.sh b/scripts/core-sync/report-failure.sh new file mode 100755 index 0000000..7e5e29a --- /dev/null +++ b/scripts/core-sync/report-failure.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +# report-failure.sh: record a failed automation run in the drift issue. +# +# Usage: report-failure.sh [--dry-run] +# +# Appends "- " to the "## Automation failures" section +# of the open "Core main drift" issue, creating the issue when none is open. +# Also writes the line to the job summary. +set -euo pipefail +# shellcheck source=scripts/core-sync/drift-issue.sh +. "$(dirname "${BASH_SOURCE[0]}")/drift-issue.sh" + +dry=0 +args=() +for a in "$@"; do + case $a in --dry-run) dry=1 ;; *) args+=("$a") ;; esac +done +[ ${#args[@]} -eq 2 ] || { echo "usage: $0 [--dry-run]" >&2; exit 2; } +line="- $(date -u +%Y-%m-%d) ${args[0]} ${args[1]}" +[ -z "${GITHUB_STEP_SUMMARY:-}" ] || echo "Automation failure: $line" >>"$GITHUB_STEP_SUMMARY" + +n=$(drift_issue_number) +if [ -n "$n" ]; then + current=$(gh issue view "$n" -R "$sdk_repo" --json body --jq .body) + report=$(awk -v h="$failures_heading" '$0 == h { exit } { print }' <<<"$current" | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}') + failures=$(failure_lines "$current") +else + report="No drift report yet; the next core-drift run fills this in." + failures= +fi +failures=$(printf '%s\n%s' "$failures" "$line" | sed '/^$/d') +body=$(compose_body "$report" "$failures") + +if [ "$dry" -eq 1 ]; then + if [ -n "$n" ]; then target="update #$n"; else target="create the issue"; fi + echo "dry-run: would $target with:" + sed 's/^/ | /' <<<"$body" + exit 0 +fi +if [ -n "$n" ]; then + gh issue edit "$n" -R "$sdk_repo" --body "$body" >/dev/null + echo "Recorded the failure in #$n." +else + gh issue create -R "$sdk_repo" --title "$drift_title" --label "$drift_label" --body "$body" +fi diff --git a/scripts/core-sync/update-drift-issue.sh b/scripts/core-sync/update-drift-issue.sh new file mode 100755 index 0000000..c174bcb --- /dev/null +++ b/scripts/core-sync/update-drift-issue.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 + +# update-drift-issue.sh: keep the single "Core main drift" issue current. +# +# Usage: update-drift-issue.sh [--dry-run] +# +# The report comes from drift-check.sh. The issue body is the report plus the +# "## Automation failures" section, which is carried over unchanged. With +# "Result: fail" or pending failures, the issue is created or its body +# replaced. With "Result: pass" and no pending failures, an open issue is +# closed with a comment, and nothing is created. +set -euo pipefail +# shellcheck source=scripts/core-sync/drift-issue.sh +. "$(dirname "${BASH_SOURCE[0]}")/drift-issue.sh" + +report= +dry=0 +for a in "$@"; do + case $a in + --dry-run) dry=1 ;; + -*) echo "usage: $0 [--dry-run]" >&2; exit 2 ;; + *) report=$a ;; + esac +done +[ -f "$report" ] || { echo "usage: $0 [--dry-run]" >&2; exit 2; } + +text=$(cat "$report") +header=$(head -1 <<<"$text") +case $header in +*"Result: pass"*) result=pass ;; +*"Result: fail"*) result=fail ;; +*) echo "ERROR: $report has no 'Result: pass|fail' header" >&2; exit 1 ;; +esac + +n=$(drift_issue_number) +failures= +[ -z "$n" ] || failures=$(failure_lines "$(gh issue view "$n" -R "$sdk_repo" --json body --jq .body)") +body=$(compose_body "$text" "$failures") + +if [ "$result" = pass ] && [ -z "$failures" ]; then + if [ -z "$n" ]; then + echo "Drift check passes and no failures are pending; no issue needed." + exit 0 + fi + echo "Closing #$n: the drift check passes and no failures are pending." + [ "$dry" -eq 1 ] && exit 0 + gh issue edit "$n" -R "$sdk_repo" --body "$body" >/dev/null + gh issue close "$n" -R "$sdk_repo" --comment "The drift check passes against core main ($(sed 's/.*Core main: \([0-9a-f]*\).*/\1/' <<<"$header")) and no automation failures are pending." + exit 0 +fi + +if [ "$dry" -eq 1 ]; then + if [ -n "$n" ]; then target="update #$n"; else target="create the issue"; fi + echo "dry-run: would $target with:" + sed 's/^/ | /' <<<"$body" + exit 0 +fi +if [ -n "$n" ]; then + gh issue edit "$n" -R "$sdk_repo" --body "$body" >/dev/null + echo "Updated #$n." +else + gh issue create -R "$sdk_repo" --title "$drift_title" --label "$drift_label" --body "$body" +fi diff --git a/scripts/vendored-core-files.txt b/scripts/vendored-core-files.txt new file mode 100644 index 0000000..3fc7d9e --- /dev/null +++ b/scripts/vendored-core-files.txt @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Files copied from NVIDIA/OpenShell at the pinned ref, one per line: +# . `mise run conformance:check` fails when a copy +# differs from core, and core bump PRs refresh them (core-sync/open-bump-pr.sh). +sdk/conformance/oauth-client-credentials.json tests/fixtures/oauth-client-credentials.json +sdk/conformance/oauth-client-credentials.json.license tests/fixtures/oauth-client-credentials.json.license +e2e/python/Dockerfile.workload tests/e2e/Dockerfile.workload