Skip to content

sync

sync #935

Workflow file for this run

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