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 '\n
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' - } > /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 '\n
Spec 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 '\n
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' + } > /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 '\n
Spec 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 @@ +{}