From a8618ad90d29004d9f06927b6b9859b8c7cfcfc0 Mon Sep 17 00:00:00 2001
From: Spencer Churchill <25377399+splch@users.noreply.github.com>
Date: Tue, 1 Sep 2026 10:27:24 -0700
Subject: [PATCH 1/3] Replace spec-drift issues with an auto-merging weekly
regeneration PR
---
.github/chainguard/spec-sync.sts.yaml | 14 ++++
.github/workflows/spec-drift.yml | 52 -------------
.github/workflows/spec-sync.yml | 105 ++++++++++++++++++++++++++
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
5 files changed, 121 insertions(+), 54 deletions(-)
create mode 100644 .github/chainguard/spec-sync.sts.yaml
delete mode 100644 .github/workflows/spec-drift.yml
create mode 100644 .github/workflows/spec-sync.yml
diff --git a/.github/chainguard/spec-sync.sts.yaml b/.github/chainguard/spec-sync.sts.yaml
new file mode 100644
index 0000000..229aa5f
--- /dev/null
+++ b/.github/chainguard/spec-sync.sts.yaml
@@ -0,0 +1,14 @@
+# spec-sync opens/updates the weekly OpenAPI-regeneration PR and merges it into THIS
+# repo only. The caller holds no key: it federates its Actions OIDC token for a
+# short-lived, self-scoped installation token. Pattern: the `self-modify` variant in
+# ionq-actions/octo-sts/docs/patterns/release-please.md.
+issuer: https://token.actions.githubusercontent.com
+# Both subject forms, so an immutable-ID opt-in or rename keeps syncing;
+# ionq=25356822, ionq-core-python=1204464907.
+subject_pattern: "^repo:(ionq/ionq-core-python|[^/@]+@25356822/[^/@:]+@1204464907):ref:refs/heads/main$"
+claim_pattern:
+ enterprise: "^ionq$"
+permissions:
+ contents: write # push the regeneration branch
+ pull_requests: write # open/update/merge the PR
+ issues: write # apply the spec-drift label (labels ride the issues API)
diff --git a/.github/workflows/spec-drift.yml b/.github/workflows/spec-drift.yml
deleted file mode 100644
index 6552b76..0000000
--- a/.github/workflows/spec-drift.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: Spec drift check
-
-on:
- schedule:
- - cron: "0 9 * * 1"
- workflow_dispatch:
-
-permissions:
- contents: read
- issues: write
-
-jobs:
- check:
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Fetch latest spec
- run: |
- BASE_URL=$(jq -r '.servers[0].url' openapi.json)
- echo "BASE_URL=${BASE_URL}" >> "$GITHUB_ENV"
- curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json
- - name: Check for drift
- id: drift
- run: |
- norm() { jq -S 'del(.info.description)' "$1"; }
- if ! diff -u --label vendored --label upstream <(norm openapi.json) <(norm /tmp/latest-spec.json) > /tmp/spec.diff; then
- echo "drifted=true" >> "$GITHUB_OUTPUT"
- fi
- - name: Open or update issue
- if: steps.drift.outputs.drifted == 'true'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- {
- echo "The spec at ${BASE_URL}/api-docs has diverged from the vendored openapi.json. Fetch the new spec and regenerate the client."
- printf '\nDiff (sorted, pretty-printed JSON)
\n\n```diff\n'
- head -c 60000 /tmp/spec.diff
- [[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n'
- printf '```\n \n'
- } > /tmp/body.md
- existing=$(gh issue list --label spec-drift --state open --json number --jq '.[0].number // empty')
- if [[ -z "$existing" ]]; then
- gh issue create \
- --title "OpenAPI spec has changed upstream" \
- --body-file /tmp/body.md \
- --label spec-drift
- else
- gh issue edit "$existing" --body-file /tmp/body.md
- fi
diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml
new file mode 100644
index 0000000..5fa070c
--- /dev/null
+++ b/.github/workflows/spec-sync.yml
@@ -0,0 +1,105 @@
+name: Spec sync
+
+on:
+ schedule:
+ - cron: "0 9 * * 1"
+ workflow_dispatch:
+
+concurrency:
+ group: spec-sync
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Fetch latest spec
+ run: |
+ BASE_URL=$(jq -r '.servers[0].url' openapi.json)
+ echo "BASE_URL=${BASE_URL}" >> "$GITHUB_ENV"
+ echo "SYNC_DATE=$(date -u +%F)" >> "$GITHUB_ENV"
+ curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json
+ - name: Check for drift
+ id: drift
+ run: |
+ norm() { jq -S 'del(.info.description)' "$1"; }
+ if ! diff -u --label vendored --label upstream <(norm openapi.json) <(norm /tmp/latest-spec.json) > /tmp/spec.diff; then
+ echo "drifted=true" >> "$GITHUB_OUTPUT"
+ fi
+ - if: steps.drift.outputs.drifted == 'true'
+ uses: ./.github/actions/setup-uv
+ - name: Regenerate client
+ if: steps.drift.outputs.drifted == 'true'
+ run: |
+ cp /tmp/latest-spec.json openapi.json
+ uv sync --group regen
+ uv run oas-patch overlay openapi.json openapi-overlay.yaml -o /tmp/patched-spec.json
+ uv run openapi-python-client generate \
+ --path /tmp/patched-spec.json \
+ --meta none \
+ --config openapi-python-client-config.yaml \
+ --custom-template-path custom-templates \
+ --output-path ionq_core \
+ --overwrite
+ - name: Compose PR body
+ if: steps.drift.outputs.drifted == 'true'
+ run: |
+ {
+ echo "## Summary"
+ echo
+ echo "Weekly sync of \`openapi.json\` with ${BASE_URL}/api-docs and regeneration of the client."
+ printf '\nSpec diff (sorted, pretty-printed JSON)
\n\n```diff\n'
+ head -c 60000 /tmp/spec.diff
+ [[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n'
+ printf '```\n \n\n'
+ echo "## Test plan"
+ echo
+ echo "The required \`staleness\` check re-runs this exact regeneration from the synced spec; \`lint\`, the \`test\` matrix, \`audit\`, and \`CodeQL\` gate the merge. This PR merges automatically once all required checks pass."
+ echo
+ echo "---"
+ echo
+ echo "> [!IMPORTANT]"
+ echo "> Most code in \`ionq_core/\` is auto-generated and overwritten on regeneration."
+ echo "> See [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes) for which files are safe to edit."
+ } > /tmp/pr-body.md
+ - name: Mint token
+ if: steps.drift.outputs.drifted == 'true'
+ id: sts
+ uses: octo-sts/action@f603d3be9d8dd9871a265776e625a27b00effe05 # v1.1.1
+ with:
+ domain: octo-sts.gh.ionq.co
+ scope: ${{ github.repository }}
+ identity: spec-sync
+ - name: Create or update PR
+ if: steps.drift.outputs.drifted == 'true'
+ id: pr
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+ with:
+ token: ${{ steps.sts.outputs.token }}
+ add-paths: |
+ openapi.json
+ ionq_core
+ branch: spec-sync
+ commit-message: Sync OpenAPI spec with upstream (${{ env.SYNC_DATE }})
+ title: Sync OpenAPI spec with upstream (${{ env.SYNC_DATE }})
+ body-path: /tmp/pr-body.md
+ labels: spec-drift
+ sign-commits: true
+ delete-branch: true
+ - name: Merge once checks pass
+ if: steps.pr.outputs.pull-request-number != ''
+ env:
+ GH_TOKEN: ${{ steps.sts.outputs.token }}
+ PR: ${{ steps.pr.outputs.pull-request-number }}
+ run: |
+ sleep 30 # let required check runs attach to the new head SHA
+ gh pr checks "$PR" --required --watch --fail-fast
+ gh pr merge "$PR" --squash --admin
diff --git a/AGENTS.md b/AGENTS.md
index a2e04d1..debc4f3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,7 +112,7 @@ Workflows live in [`.github/workflows/`](.github/workflows/) — `ls` it for the
- **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This is what catches hand-edits to generated files.
- **`integration.yml`** is on a weekly cron and `workflow_dispatch` only — it does not run per PR, so don't rely on it for fast feedback.
-- **`spec-drift.yml`** opens or updates a `spec-drift`-labeled issue when upstream `openapi.json` diverges from the vendored copy.
+- **`spec-sync.yml`** opens (or updates) a `spec-drift`-labeled PR re-vendoring `openapi.json` and regenerating the client when upstream diverges, then squash-merges it once all required checks pass. It authenticates via octo-sts ([trust policy](.github/chainguard/spec-sync.sts.yaml)); the merge relies on the octo-sts App's PR-only bypass of the `main` ruleset.
- **`release.yml`** triggers on `v*` tags only and refuses mismatched tag/version pairs or republishing existing PyPI versions.
When authoring a new workflow, use the local [`.github/actions/setup-uv`](.github/actions/setup-uv) composite action rather than `astral-sh/setup-uv` directly, for consistency with the existing matrix.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a175603..a8c10f0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -82,7 +82,7 @@ uv run openapi-python-client generate \
Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format.
-Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream.
+Commit the regenerated files alongside the spec or template change that caused them. Spec drift is handled weekly by [`spec-sync.yml`](.github/workflows/spec-sync.yml), which opens a PR re-vendoring `openapi.json` and regenerating the client (the normalized spec diff is in the PR body), then merges it automatically once all required checks pass.
## Pull request workflow
From 654ff37ead09c6553b61850faeeb219ceaee8dcc Mon Sep 17 00:00:00 2001
From: Spencer Churchill <25377399+splch@users.noreply.github.com>
Date: Tue, 1 Sep 2026 10:33:24 -0700
Subject: [PATCH 2/3] Shorten spec-sync workflow, policy, and docs
---
.github/chainguard/spec-sync.sts.yaml | 15 ++++------
.github/workflows/spec-sync.yml | 42 ++++++++-------------------
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
4 files changed, 20 insertions(+), 41 deletions(-)
diff --git a/.github/chainguard/spec-sync.sts.yaml b/.github/chainguard/spec-sync.sts.yaml
index 229aa5f..9908bb4 100644
--- a/.github/chainguard/spec-sync.sts.yaml
+++ b/.github/chainguard/spec-sync.sts.yaml
@@ -1,14 +1,11 @@
-# spec-sync opens/updates the weekly OpenAPI-regeneration PR and merges it into THIS
-# repo only. The caller holds no key: it federates its Actions OIDC token for a
-# short-lived, self-scoped installation token. Pattern: the `self-modify` variant in
-# ionq-actions/octo-sts/docs/patterns/release-please.md.
+# spec-sync opens the weekly OpenAPI-regeneration PR and merges it into this repo only,
+# holding no key. Pattern: self-modify in ionq-actions/octo-sts/docs/patterns/release-please.md.
issuer: https://token.actions.githubusercontent.com
-# Both subject forms, so an immutable-ID opt-in or rename keeps syncing;
-# ionq=25356822, ionq-core-python=1204464907.
+# Name and immutable-ID subject forms; ionq=25356822, ionq-core-python=1204464907.
subject_pattern: "^repo:(ionq/ionq-core-python|[^/@]+@25356822/[^/@:]+@1204464907):ref:refs/heads/main$"
claim_pattern:
enterprise: "^ionq$"
permissions:
- contents: write # push the regeneration branch
- pull_requests: write # open/update/merge the PR
- issues: write # apply the spec-drift label (labels ride the issues API)
+ contents: write
+ pull_requests: write
+ issues: write # PR labels ride the issues API
diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml
index 5fa070c..e6726e9 100644
--- a/.github/workflows/spec-sync.yml
+++ b/.github/workflows/spec-sync.yml
@@ -21,18 +21,21 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - name: Fetch latest spec
- run: |
- BASE_URL=$(jq -r '.servers[0].url' openapi.json)
- echo "BASE_URL=${BASE_URL}" >> "$GITHUB_ENV"
- echo "SYNC_DATE=$(date -u +%F)" >> "$GITHUB_ENV"
- curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json
- name: Check for drift
id: drift
run: |
+ BASE_URL=$(jq -r '.servers[0].url' openapi.json)
+ curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json
norm() { jq -S 'del(.info.description)' "$1"; }
if ! diff -u --label vendored --label upstream <(norm openapi.json) <(norm /tmp/latest-spec.json) > /tmp/spec.diff; then
echo "drifted=true" >> "$GITHUB_OUTPUT"
+ {
+ echo "Weekly sync of \`openapi.json\` with ${BASE_URL}/api-docs and client regeneration. Merges automatically once all required checks pass."
+ printf '\nDiff (sorted, pretty-printed JSON)
\n\n```diff\n'
+ head -c 60000 /tmp/spec.diff
+ [[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n'
+ printf '```\n \n'
+ } > /tmp/pr-body.md
fi
- if: steps.drift.outputs.drifted == 'true'
uses: ./.github/actions/setup-uv
@@ -49,27 +52,6 @@ jobs:
--custom-template-path custom-templates \
--output-path ionq_core \
--overwrite
- - name: Compose PR body
- if: steps.drift.outputs.drifted == 'true'
- run: |
- {
- echo "## Summary"
- echo
- echo "Weekly sync of \`openapi.json\` with ${BASE_URL}/api-docs and regeneration of the client."
- printf '\nSpec diff (sorted, pretty-printed JSON)
\n\n```diff\n'
- head -c 60000 /tmp/spec.diff
- [[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n'
- printf '```\n \n\n'
- echo "## Test plan"
- echo
- echo "The required \`staleness\` check re-runs this exact regeneration from the synced spec; \`lint\`, the \`test\` matrix, \`audit\`, and \`CodeQL\` gate the merge. This PR merges automatically once all required checks pass."
- echo
- echo "---"
- echo
- echo "> [!IMPORTANT]"
- echo "> Most code in \`ionq_core/\` is auto-generated and overwritten on regeneration."
- echo "> See [CONTRIBUTING.md](https://github.com/ionq/ionq-core-python/blob/main/CONTRIBUTING.md#proposing-changes) for which files are safe to edit."
- } > /tmp/pr-body.md
- name: Mint token
if: steps.drift.outputs.drifted == 'true'
id: sts
@@ -88,14 +70,14 @@ jobs:
openapi.json
ionq_core
branch: spec-sync
- commit-message: Sync OpenAPI spec with upstream (${{ env.SYNC_DATE }})
- title: Sync OpenAPI spec with upstream (${{ env.SYNC_DATE }})
+ commit-message: Sync OpenAPI spec with upstream
+ title: Sync OpenAPI spec with upstream
body-path: /tmp/pr-body.md
labels: spec-drift
sign-commits: true
delete-branch: true
- name: Merge once checks pass
- if: steps.pr.outputs.pull-request-number != ''
+ if: steps.pr.outputs.pull-request-number
env:
GH_TOKEN: ${{ steps.sts.outputs.token }}
PR: ${{ steps.pr.outputs.pull-request-number }}
diff --git a/AGENTS.md b/AGENTS.md
index debc4f3..dcf06a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,7 +112,7 @@ Workflows live in [`.github/workflows/`](.github/workflows/) — `ls` it for the
- **`generated.yml`** runs the regenerator on every PR and fails if `git diff ionq_core/` is non-empty. This is what catches hand-edits to generated files.
- **`integration.yml`** is on a weekly cron and `workflow_dispatch` only — it does not run per PR, so don't rely on it for fast feedback.
-- **`spec-sync.yml`** opens (or updates) a `spec-drift`-labeled PR re-vendoring `openapi.json` and regenerating the client when upstream diverges, then squash-merges it once all required checks pass. It authenticates via octo-sts ([trust policy](.github/chainguard/spec-sync.sts.yaml)); the merge relies on the octo-sts App's PR-only bypass of the `main` ruleset.
+- **`spec-sync.yml`** opens a `spec-drift`-labeled PR re-vendoring `openapi.json` and regenerating the client when upstream drifts, then squash-merges it once all required checks pass (auth: [octo-sts trust policy](.github/chainguard/spec-sync.sts.yaml); merge: the App's PR-only `main`-ruleset bypass).
- **`release.yml`** triggers on `v*` tags only and refuses mismatched tag/version pairs or republishing existing PyPI versions.
When authoring a new workflow, use the local [`.github/actions/setup-uv`](.github/actions/setup-uv) composite action rather than `astral-sh/setup-uv` directly, for consistency with the existing matrix.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a8c10f0..d46a468 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -82,7 +82,7 @@ uv run openapi-python-client generate \
Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format.
-Commit the regenerated files alongside the spec or template change that caused them. Spec drift is handled weekly by [`spec-sync.yml`](.github/workflows/spec-sync.yml), which opens a PR re-vendoring `openapi.json` and regenerating the client (the normalized spec diff is in the PR body), then merges it automatically once all required checks pass.
+Commit the regenerated files alongside the spec or template change that caused them. Spec drift is handled weekly by [`spec-sync.yml`](.github/workflows/spec-sync.yml), which opens a regeneration PR carrying the spec diff and merges it once all required checks pass.
## Pull request workflow
From cea0db7825ca983aac4bb7f4008b470e5c2dda53 Mon Sep 17 00:00:00 2001
From: Spencer Churchill <25377399+splch@users.noreply.github.com>
Date: Tue, 1 Sep 2026 14:12:36 -0700
Subject: [PATCH 3/3] Poll for required checks before watching; exempt sync PRs
from CHANGELOG
---
.github/workflows/spec-sync.yml | 3 ++-
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
package-lock.json | 6 ++++++
package.json | 1 +
5 files changed, 11 insertions(+), 3 deletions(-)
create mode 100644 package-lock.json
create mode 100644 package.json
diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml
index e6726e9..57f35ba 100644
--- a/.github/workflows/spec-sync.yml
+++ b/.github/workflows/spec-sync.yml
@@ -82,6 +82,7 @@ jobs:
GH_TOKEN: ${{ steps.sts.outputs.token }}
PR: ${{ steps.pr.outputs.pull-request-number }}
run: |
- sleep 30 # let required check runs attach to the new head SHA
+ # Poll until required check runs attach; gh errors instead of waiting when none are reported yet.
+ for _ in $(seq 30); do gh pr checks "$PR" --required --json name >/dev/null 2>&1 && break; sleep 10; done
gh pr checks "$PR" --required --watch --fail-fast
gh pr merge "$PR" --squash --admin
diff --git a/AGENTS.md b/AGENTS.md
index dcf06a0..dce9ee7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -121,7 +121,7 @@ When authoring a new workflow, use the local [`.github/actions/setup-uv`](.githu
- Branch off `main`. CODEOWNERS is `@ionq/developer-tools`.
- PR titles become release-notes lines (`gh release create --generate-notes`). Imperative mood, user-facing, no leading ticket number.
-- User-visible changes go under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
+- User-visible changes go under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Automated `spec-sync` PRs skip this; write their entries at release time from the merged `spec-drift`-labeled PRs.
- Release: bump `pyproject.toml` `[project] version`, promote `[Unreleased]` → `[X.Y.Z]` in `CHANGELOG.md`, tag `vX.Y.Z`. `release.yml` rejects mismatched tag/version pairs and refuses to republish an existing PyPI version.
## Things to avoid (and what to do instead)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d46a468..a62f01e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -94,7 +94,7 @@ Commit the regenerated files alongside the spec or template change that caused t
There is no enforced commit-message format, but PR titles become release notes via `gh release create --generate-notes`. Write each title as the line you would want to see in a changelog: imperative mood, user-facing, no leading ticket number.
-User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) under the next release section, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
+User-visible changes should also be reflected in [CHANGELOG.md](CHANGELOG.md) under the next release section, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Automated `spec-sync` PRs are the exception: they merge without an entry, and the release author writes entries for them from the merged `spec-drift`-labeled PRs, whose bodies carry each spec diff.
## Contributor License Agreement
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9510777
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "ionq-core-python",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{}