Repository navigation
sync #954
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: sync | |
| # Runs hourly once enabled. When the schema hasn't changed the job exits in | |
| # seconds, which is what makes that affordable. | |
| # | |
| # Do not rename this file. PyPI's trusted-publishing binding is registered | |
| # against the repo and this filename, so a rename breaks publishing silently. | |
| on: | |
| # Avoid minute 0, which is GitHub's most contended scheduling slot. | |
| schedule: | |
| - cron: "17 * * * *" # hourly | |
| workflow_dispatch: | |
| inputs: | |
| # auto: major if oasdiff or the API surface check finds a break, | |
| # otherwise minor. major forces one for a break neither gate sees. | |
| # There is no minor override: a detected break never ships as a minor. | |
| bump: | |
| description: "Which part of the version to bump" | |
| type: choice | |
| options: [auto, major] | |
| default: auto | |
| dry_run: | |
| description: "Regenerate and build, but don't tag, release or publish" | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write # commit + tag + create release | |
| issues: write # open an issue when a release is stuck | |
| id-token: write # PyPI trusted publishing (job-scoped, hence one job) | |
| concurrency: | |
| group: sync | |
| cancel-in-progress: false | |
| env: | |
| # Pinned deliberately: oasdiff decides whether a release is a major. | |
| OASDIFF_VERSION: "1.32.1" | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 # need full history + tags to read the last version | |
| # Goes to a scratch path because oasdiff needs both versions to compare; | |
| # writing straight over openapi.json would lose the old one. | |
| - name: Fetch latest public schema | |
| run: make fetch OUT=openapi.json.new | |
| - name: Exit early if schema unchanged | |
| id: changed | |
| run: | | |
| if diff -q openapi.json openapi.json.new >/dev/null 2>&1; then | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| echo "Schema unchanged; nothing to do." | |
| else | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - if: steps.changed.outputs.changed == 'true' | |
| uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| with: | |
| # The default key hashes pyproject.toml, which every release rewrites | |
| # with a new version, so it would miss every time. The pins that | |
| # actually decide what gets downloaded live in the Makefile. | |
| cache-dependency-glob: Makefile | |
| # mypy over 1895 files is the slowest part of verify: ~6s cold against | |
| # ~0.2s warm. make generate rewrites every file, but mypy falls back to | |
| # the source hash, and most models are untouched between releases. | |
| - if: steps.changed.outputs.changed == 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: .mypy_cache | |
| key: mypy-${{ hashFiles('Makefile') }}-${{ github.sha }} | |
| restore-keys: mypy-${{ hashFiles('Makefile') }}- | |
| - name: Check for breaking changes | |
| if: steps.changed.outputs.changed == 'true' | |
| id: breaking | |
| run: | | |
| set -euo pipefail | |
| curl -sfSL "https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" \ | |
| | tar -xz -C /tmp oasdiff | |
| # First release: nothing to compare against. | |
| if [ ! -f openapi.json ]; then | |
| echo "breaking=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # oasdiff-severity.txt adjusts four rules in both directions; it | |
| # takes no comments of its own. | |
| # | |
| # Down to WARN: an added response enum value, because our generated | |
| # enums accept unknown values (templates/str_enum.py.jinja), and a | |
| # tightened request minimum, because the generator emits no range | |
| # validation so the client cannot behave differently. Left at ERR | |
| # these halt about a quarter of real schema changes — over 49 | |
| # consecutive changes from sdk-go's history, 12 would have stopped. | |
| # | |
| # Up to ERR: a removed operationId or component schema. Neither | |
| # changes the wire contract, so oasdiff rates them info, but both | |
| # rename something consumers import. | |
| report="$(/tmp/oasdiff breaking openapi.json openapi.json.new \ | |
| --severity-levels oasdiff-severity.txt --fail-on ERR 2>&1)" && rc=0 || rc=$? | |
| echo "$report" | |
| # 1 means it found breaking changes. Anything else means it couldn't | |
| # do the comparison at all — a truncated fetch, an HTML error page — | |
| # which is not a breaking change and mustn't be released as one. | |
| if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then | |
| echo "::error::oasdiff exited $rc without comparing the schemas." | |
| exit 1 | |
| fi | |
| # A break does not stop the run: it makes this release a major. | |
| # The report goes into the release notes so the major explains | |
| # itself. | |
| if [ "$rc" -ne 0 ]; then | |
| echo "breaking=true" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### oasdiff: breaking changes to the API" | |
| echo | |
| echo '```' | |
| echo "$report" | |
| echo '```' | |
| echo | |
| } >> /tmp/breaks.md | |
| else | |
| echo "breaking=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Before the version is chosen, because a removal here makes this | |
| # release a major. | |
| - name: Regenerate and check the API surface | |
| if: steps.changed.outputs.changed == 'true' | |
| id: surface | |
| run: | | |
| set -euo pipefail | |
| mv openapi.json.new openapi.json | |
| make generate | |
| make install | |
| # Exit 3 is removals only; anything else is a broken build and | |
| # stops the run. | |
| .venv-verify/bin/python scripts/verify_import.py api-surface.txt 2>/tmp/surface.txt && rc=0 || rc=$? | |
| cat /tmp/surface.txt >&2 | |
| if [ "$rc" -eq 3 ]; then | |
| echo "removed=true" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Removed from the Python API" | |
| echo | |
| echo '```' | |
| grep '^ ' /tmp/surface.txt | |
| echo '```' | |
| echo | |
| } >> /tmp/breaks.md | |
| elif [ "$rc" -ne 0 ]; then | |
| exit "$rc" | |
| else | |
| echo "removed=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # After the surface check, because it is one of the two signals. Patch | |
| # is for hand-fixes and never cut here. | |
| - name: Decide next version | |
| if: steps.changed.outputs.changed == 'true' | |
| id: version | |
| env: | |
| BUMP_INPUT: ${{ inputs.bump || 'auto' }} | |
| BREAKING: ${{ steps.breaking.outputs.breaking }} | |
| REMOVED: ${{ steps.surface.outputs.removed }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$BUMP_INPUT" = "major" ] || [ "$BREAKING" = "true" ] || [ "$REMOVED" = "true" ]; then | |
| BUMP=major | |
| else | |
| BUMP=minor | |
| fi | |
| # Highest version, not `git describe`'s nearest-by-commit-distance: | |
| # a hand-cut patch tag would otherwise make us recompute a version | |
| # that already exists. The pattern also skips any non-semver tag, | |
| # which awk would silently turn into v1.0.0. | |
| BASE="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1)" | |
| if [ -z "$BASE" ]; then | |
| NEXT="v1.0.0" | |
| elif [ "$BUMP" = "major" ]; then | |
| NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.0.0", $1+1}')" | |
| else | |
| NEXT="$(echo "${BASE#v}" | awk -F. '{printf "v%d.%d.0", $1, $2+1}')" | |
| fi | |
| if git rev-parse -q --verify "refs/tags/$NEXT" >/dev/null; then | |
| echo "::error::$NEXT already exists. A previous run tagged without" | |
| echo "::error::publishing, or a tag was cut by hand. Resolve before releasing." | |
| exit 1 | |
| fi | |
| echo "Next version: $NEXT (base: ${BASE:-none}, bump: $BUMP, oasdiff break: $BREAKING, surface removals: $REMOVED)" | |
| echo "next=$NEXT" >> "$GITHUB_OUTPUT" | |
| # Release notes: the breaks, if any, above GitHub's generated notes. | |
| if [ "$BUMP" = "major" ]; then | |
| { | |
| echo "## Breaking changes" | |
| echo | |
| if [ -f /tmp/breaks.md ]; then | |
| cat /tmp/breaks.md | |
| else | |
| echo "Released as a major by hand (bump: major); neither gate reported a break." | |
| fi | |
| } > /tmp/release-notes.md | |
| else | |
| : > /tmp/release-notes.md | |
| fi | |
| # Before make test, so the wheel we check is the wheel we publish. | |
| - name: Set the release version | |
| if: steps.changed.outputs.changed == 'true' | |
| env: | |
| NEXT: ${{ steps.version.outputs.next }} | |
| run: | | |
| set -euo pipefail | |
| sed -i -E "0,/^version = /s/^version = .*/version = \"${NEXT#v}\"/" pyproject.toml | |
| grep -m1 '^version = ' pyproject.toml | |
| - name: Record the surface and test | |
| if: steps.changed.outputs.changed == 'true' | |
| run: | | |
| set -euo pipefail | |
| # Rewritten on every release, removals and additions included, so | |
| # the next release checks against what this one ships. Before the | |
| # tests, so the check inside make verify runs against it. | |
| make surface | |
| # make test depends on make verify: builds the wheel, imports every | |
| # module, type-checks, checks the API surface, then runs the tests. | |
| # The generated code changes on every release, which is exactly when | |
| # behaviour needs checking. | |
| make test | |
| # Every step above this point fails without committing anything, so the | |
| # next run sees the same schema diff and fails identically. Left alone | |
| # that is a red cron every hour and no other signal. | |
| - name: Report a stuck release | |
| if: failure() && steps.changed.outputs.changed == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| title="Release is stuck — regeneration or verification is failing" | |
| gh label create release-stuck \ | |
| --description "The hourly sync cannot complete and will retry forever" \ | |
| --color B60205 2>/dev/null || true | |
| if gh issue list --label release-stuck --state open --limit 1 \ | |
| --json number --jq '.[0].number // empty' | grep -q .; then | |
| echo "Already reported." | |
| exit 0 | |
| fi | |
| gh issue create --title "$title" --label release-stuck --body "$(cat <<EOF | |
| The hourly sync failed after detecting a schema change, before committing anything. The next run will see the same diff and fail the same way, so this repeats hourly until someone looks. | |
| Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID | |
| Likely causes: the generator rejected the new schema, a post-generation fix in \`scripts/\` stopped matching, or verification failed. Removals from the API surface do not land here: they make the release a major. | |
| EOF | |
| )" | |
| - name: Report what a dry run would have done | |
| if: steps.changed.outputs.changed == 'true' && inputs.dry_run | |
| env: | |
| NEXT: ${{ steps.version.outputs.next }} | |
| run: | | |
| { | |
| echo "### Dry run — nothing was tagged or published" | |
| echo | |
| echo "Would have released **$NEXT**." | |
| echo | |
| cat /tmp/release-notes.md | |
| echo | |
| echo "Built artefacts:" | |
| echo '```' | |
| ls -1 dist | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Commit, tag and release | |
| if: steps.changed.outputs.changed == 'true' && !inputs.dry_run | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| NEXT: ${{ steps.version.outputs.next }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "incident-io-bot" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add openapi.json pyproject.toml incident_io api-surface.txt | |
| git commit -m "Update to schema as of $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| Regenerated from https://api.incident.io/v1/openapiV3.json" | |
| git tag "$NEXT" | |
| # --atomic: otherwise the tag can land while the branch push is | |
| # refused, and every later run recomputes this version and dies on | |
| # "tag already exists". | |
| git push --atomic origin HEAD "refs/tags/$NEXT" | |
| # --notes-file with --generate-notes puts the file above the | |
| # generated notes. The file is empty on a minor. | |
| gh release create "$NEXT" --generate-notes --notes-file /tmp/release-notes.md --title "$NEXT" | |
| # Has to live in this job: id-token is job-scoped, and a tag pushed with | |
| # GITHUB_TOKEN won't trigger a separate workflow. | |
| - name: Publish to PyPI | |
| if: steps.changed.outputs.changed == 'true' && !inputs.dry_run | |
| uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 |