Repository navigation
slim-release-published #44
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: Slim Release Published | |
| # Reconciles the stack catalog against supabase/slim-services releases. See | |
| # docs/adr/0026-slim-artifact-mirrors.md, "Hotfix and upgrade pickup". | |
| on: | |
| repository_dispatch: | |
| types: | |
| - slim-release-published | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: slim-release-published-${{ github.event.client_payload.service }} | |
| cancel-in-progress: false | |
| # The default `single` cancels a pending run when another is queued, losing its release-visibility wait. | |
| queue: max | |
| jobs: | |
| pickup: | |
| runs-on: ubuntu-latest | |
| # Setup (~10 min) + release visibility (<1 min) + the S3-mirror wait (up to ~10 min, normally only | |
| # for the first planned item) + pin, render and push per item + the merge-queue wait (up to 30 min). | |
| timeout-minutes: 75 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout the default branch | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| persist-credentials: false | |
| # `Artifacts.ts` and `render-service-dockerfile.ts` import locked workspace dependencies. | |
| - name: Setup | |
| uses: ./.github/actions/setup | |
| with: | |
| dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} | |
| - name: Record the base commit | |
| id: base | |
| run: echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" | |
| # Re-echoes a validate-payload failure so its `::error ::…` line surfaces as an annotation. | |
| - name: Validate payload | |
| id: validate | |
| env: | |
| SERVICE: ${{ github.event.client_payload.service }} | |
| UPSTREAM_VERSION: ${{ github.event.client_payload.upstream_version }} | |
| REVISION: ${{ github.event.client_payload.revision }} | |
| RELEASE_VERSION: ${{ github.event.client_payload.release_version }} | |
| run: | | |
| set -euo pipefail | |
| if ! output="$(bun .github/scripts/sync-artifacts-catalog.ts validate-payload \ | |
| --service "$SERVICE" --upstream "$UPSTREAM_VERSION" --revision "$REVISION" --release "$RELEASE_VERSION")"; then | |
| echo "$output" | |
| exit 1 | |
| fi | |
| echo "$output" >>"$GITHUB_OUTPUT" | |
| # `--expect-release` waits for the releases API to list the release behind this dispatch. | |
| - name: Plan updates | |
| id: plan | |
| env: | |
| SERVICE: ${{ steps.validate.outputs.service }} | |
| RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| bun .github/scripts/sync-artifacts-catalog.ts plan-updates --service "$SERVICE" --format lines \ | |
| --expect-release "$RELEASE_VERSION" --output "${RUNNER_TEMP}/slim-updates.tsv" | |
| count="$(wc -l < "${RUNNER_TEMP}/slim-updates.tsv" | tr -d ' ')" | |
| echo "count=${count}" >>"$GITHUB_OUTPUT" | |
| - name: Nothing to do | |
| if: steps.plan.outputs.count == '0' | |
| env: | |
| SERVICE: ${{ steps.validate.outputs.service }} | |
| RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} | |
| run: echo "No catalog updates for ${SERVICE} from ${RELEASE_VERSION}; nothing to do." | |
| - name: Install regctl | |
| if: steps.plan.outputs.count != '0' | |
| run: | | |
| set -euo pipefail | |
| install -d "${RUNNER_TEMP}/regctl-bin" | |
| curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ | |
| https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 | |
| echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - | |
| chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" | |
| echo "${RUNNER_TEMP}/regctl-bin" >>"$GITHUB_PATH" | |
| "${RUNNER_TEMP}/regctl-bin/regctl" version | |
| - name: Generate token | |
| if: steps.plan.outputs.count != '0' | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| client-id: ${{ vars.GH_APP_CLIENT_ID }} | |
| private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} | |
| permission-contents: write | |
| permission-pull-requests: write | |
| # One branch per planned item, rebuilt from the recorded base commit, never from a previous | |
| # iteration's HEAD; `--release` pins the planned release, never "highest at apply time". | |
| # `bun`/`pnpm` run with the tokens unset: only `git push` and `gh` ever see them. | |
| # A merge-queued branch rejects pushes; see ADR 0026 "Hotfix and upgrade pickup" for the wait and hand-off. | |
| - name: Apply planned updates | |
| if: steps.plan.outputs.count != '0' | |
| env: | |
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| SERVICE: ${{ steps.validate.outputs.service }} | |
| UPSTREAM_VERSION: ${{ steps.validate.outputs.upstream_version }} | |
| REVISION: ${{ steps.validate.outputs.revision }} | |
| RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} | |
| REPLAY: ${{ github.event.client_payload.replay }} | |
| BASE_SHA: ${{ steps.base.outputs.sha }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| push_url="${PUSH_REMOTE_URL:-https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}" | |
| manual_prefix="bun .github/scripts/sync-artifacts-catalog.ts --service ${SERVICE} --release" | |
| in_merge_queue() { | |
| # shellcheck disable=SC2016 # `$owner`, `$name` and `$branch` are GraphQL variables. | |
| GH_TOKEN="$APP_TOKEN" gh api graphql \ | |
| -f query='query($owner: String!, $name: String!, $branch: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $branch, states: OPEN, first: 10) { nodes { isInMergeQueue isCrossRepository } } } }' \ | |
| -f owner="${GITHUB_REPOSITORY%/*}" -f name="${GITHUB_REPOSITORY#*/}" -f branch="$1" \ | |
| --jq '[.data.repository.pullRequests.nodes[] | select(.isCrossRepository | not) | .isInMergeQueue] | any' | |
| } | |
| # Reads records on fd 3, not stdin, so commands the loop runs (`bun`, `gh`, `git`) never | |
| # consume bytes meant for `read`. | |
| while IFS=$'\x1f' read -r -u 3 kind branch title release from; do | |
| if [ -z "$kind" ] || [ -z "$branch" ] || [ -z "$title" ] || [ -z "$release" ] || [ -z "$from" ]; then | |
| echo "::error ::Malformed plan-updates line: kind='${kind}' branch='${branch}' title='${title}' release='${release}' from='${from}'." | |
| exit 1 | |
| fi | |
| git checkout -B "$branch" "$BASE_SHA" | |
| env -u APP_TOKEN bun .github/scripts/sync-artifacts-catalog.ts --service "$SERVICE" --release "$release" | |
| env -u APP_TOKEN -u GITHUB_TOKEN pnpm exec oxfmt --config .oxfmtrc.json packages/stack/src/Artifacts.ts | |
| env -u APP_TOKEN -u GITHUB_TOKEN bun apps/cli/scripts/render-service-dockerfile.ts | |
| generated_files=( | |
| packages/stack/src/Artifacts.ts | |
| apps/cli/src/shared/services/Dockerfile | |
| apps/cli-go/pkg/config/templates/Dockerfile | |
| ) | |
| if git diff --quiet -- "${generated_files[@]}"; then | |
| echo "${branch}: catalog and Dockerfiles already match ${release}; skipping." | |
| continue | |
| fi | |
| git add -- "${generated_files[@]}" | |
| git commit -m "$title" | |
| if ! push_output="$(git push --force "$push_url" "HEAD:refs/heads/${branch}" 2>&1)"; then | |
| echo "$push_output" | |
| if ! grep -q "queued for merging cannot be updated" <<<"$push_output"; then | |
| echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." | |
| exit 1 | |
| fi | |
| manual_steps="run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." | |
| queued_steps="Once ${branch} leaves the merge queue, ${manual_steps}" | |
| # The app token minted just before this step expires after one hour; capping the wait at | |
| # 50 minutes into the step leaves room for the lookups and the dispatch that follow it. | |
| deadline=$((SECONDS + 1800 < 3000 ? SECONDS + 1800 : 3000)) | |
| while :; do | |
| if ! queued="$(in_merge_queue "$branch")"; then | |
| echo "::error ::Failed to read the merge-queue state of ${branch}. ${queued_steps}" | |
| exit 1 | |
| fi | |
| case "$queued" in | |
| false) break ;; | |
| true) ;; | |
| *) | |
| echo "::error ::Unexpected merge-queue state '${queued}' for ${branch}. ${queued_steps}" | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "$SECONDS" -ge "$deadline" ]; then | |
| echo "::error ::${branch} is still in the merge queue. ${queued_steps}" | |
| exit 1 | |
| fi | |
| sleep 30 | |
| done | |
| # `replay` counts consecutive re-sends; anything but 0-2 stops the chain. | |
| if ! [[ "${REPLAY:-0}" =~ ^[0-2]$ ]]; then | |
| echo "::error ::Stopped re-dispatching after repeated merge-queue rejections of ${branch}; ${manual_steps}" | |
| exit 1 | |
| fi | |
| if ! GH_TOKEN="$APP_TOKEN" gh api "repos/${GITHUB_REPOSITORY}/dispatches" \ | |
| -f event_type=slim-release-published \ | |
| -f "client_payload[service]=${SERVICE}" \ | |
| -f "client_payload[upstream_version]=${UPSTREAM_VERSION}" \ | |
| -f "client_payload[revision]=${REVISION}" \ | |
| -f "client_payload[release_version]=${RELEASE_VERSION}" \ | |
| -f "client_payload[replay]=$((${REPLAY:-0} + 1))"; then | |
| echo "::error ::Failed to re-dispatch slim-release-published for ${SERVICE} ${RELEASE_VERSION}. Re-run this job." | |
| exit 1 | |
| fi | |
| echo "::warning ::${branch} was held by a merge-queued pull request; re-dispatched slim-release-published so a fresh run re-plans from ${DEFAULT_BRANCH}." | |
| exit 0 | |
| fi | |
| echo "$push_output" | |
| if [ "$kind" = "upgrade" ]; then | |
| action="bumps" | |
| else | |
| action="pins" | |
| fi | |
| # Names the release this PR actually pins, not necessarily RELEASE_VERSION: postgres | |
| # can plan a hotfix on one line and an unrelated upgrade on another in one dispatch. | |
| body="$(printf 'This %s %s from %s to %s.\n\nhttps://github.com/supabase/slim-services/releases/tag/%s-%s\n\nPlanned after supabase/slim-services published %s. This branch is rewritten from %s whenever a newer relevant release arrives.\n' \ | |
| "$action" "$SERVICE" "$from" "$release" "$SERVICE" "$release" "$RELEASE_VERSION" "$DEFAULT_BRANCH")" | |
| # `--head` matches by branch name only and ignores the owner, so a fork PR with the | |
| # same head branch name would otherwise match too; `isCrossRepository` excludes it. | |
| existing="$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$branch" --base "$DEFAULT_BRANCH" --state open --json number,isCrossRepository --jq 'map(select(.isCrossRepository | not)) | .[0].number // empty')" | |
| if [ -n "$existing" ]; then | |
| if ! GH_TOKEN="$APP_TOKEN" gh pr edit "$existing" --title "$title" --body "$body"; then | |
| echo "::error ::Pushed ${branch} but failed to edit pull request #${existing}. Run manually: ${manual_prefix} ${release}, then edit the pull request from ${branch} by hand." | |
| exit 1 | |
| fi | |
| elif ! GH_TOKEN="$APP_TOKEN" gh pr create \ | |
| --title "$title" \ | |
| --body "$body" \ | |
| --head "$branch" \ | |
| --base "$DEFAULT_BRANCH"; then | |
| echo "::error ::Pushed ${branch} but failed to open a pull request. Run manually: ${manual_prefix} ${release}, then open a pull request from ${branch} against ${DEFAULT_BRANCH} by hand." | |
| exit 1 | |
| fi | |
| done 3< "${RUNNER_TEMP}/slim-updates.tsv" |