Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 187 additions & 7 deletions .github/workflows/release-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ jobs:
DISPATCH_DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# Push events are never dry; workflow_dispatch dry-runs unless the
# operator explicitly unticks the input.
if [[ "$EVENT" == "workflow_dispatch" && "$DISPATCH_DRY_RUN" == "true" ]]; then
echo "dry_run=true" >> "$GITHUB_OUTPUT"
else
echo "dry_run=false" >> "$GITHUB_OUTPUT"
fi
# semantic-release echoes commit-derived text (messages, notes) to
# this step's log; a commit message line starting with `::` would
# otherwise be interpreted as a workflow command (e.g. `::add-mask::`
Expand All @@ -93,13 +100,6 @@ jobs:
echo "::stop-commands::${resume_token}"
trap 'echo "::${resume_token}::"' EXIT
pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md"
# Push events are never dry; workflow_dispatch dry-runs unless the
# operator explicitly unticks the input.
if [[ "$EVENT" == "workflow_dispatch" && "$DISPATCH_DRY_RUN" == "true" ]]; then
echo "dry_run=true" >> "$GITHUB_OUTPUT"
else
echo "dry_run=false" >> "$GITHUB_OUTPUT"
fi

# The build, gate, and pack steps also run on private-blocked pushes
# (should_release=false, version set): if `private` were ever flipped
Expand Down Expand Up @@ -298,6 +298,50 @@ jobs:
npm publish "./${tarball}" --ignore-scripts --provenance --tag "${NPM_TAG}"
fi

# "Published successfully" for the notification jobs below means
# REGISTRY-VISIBLE with the reviewed bytes: probe npm until the version
# resolves (the registry can lag a publish by a few seconds) and its
# integrity matches the reviewed tarball. Runs before the tag push so a
# tag is never blessed for bytes this run couldn't confirm on the
# registry; the dist-tag assertion deliberately lives AFTER the tag push
# so a dist-tag propagation hiccup can't strand the release live on npm
# with origin untagged. A transient failure here is safe to re-run — the
# publish step's registry probe skips the republish and this check
# repeats — though a re-run of this job re-arms the config-release
# approval gate and costs a second human approval — hence the generous
# (~2 minute) visibility budget.
- name: Verify the release is live on npm
working-directory: ${{ runner.temp }}/config-release
run: |
set -euo pipefail
tarball="supabase-config-${VERSION}.tgz"
reviewed_integrity="sha512-$(openssl dgst -sha512 -binary "${tarball}" | base64 -w0)"
npm_err="$RUNNER_TEMP/npm-view-err.log"
registry_integrity=""
for delay in 0 2 3 5 8 13 21 30 30; do
sleep "$delay"
if registry_integrity="$(npm view --prefer-online "@supabase/config@${VERSION}" dist.integrity 2>"$npm_err")" \
&& [[ -n "$registry_integrity" ]]; then
break
fi
registry_integrity=""
done
if [[ -z "$registry_integrity" ]]; then
echo "@supabase/config@${VERSION} is not visible on the registry after publishing." >&2
if [[ -s "$npm_err" ]]; then
echo "Last npm error output:" >&2
cat "$npm_err" >&2
fi
exit 1
fi
if [[ "$registry_integrity" != "$reviewed_integrity" ]]; then
echo "@supabase/config@${VERSION} on npm does not match the reviewed tarball:" >&2
echo " registry: ${registry_integrity}" >&2
echo " reviewed: ${reviewed_integrity}" >&2
exit 1
fi
echo "@supabase/config@${VERSION} live on npm with the reviewed bytes."

- name: Configure git for release pushes
run: |
git config user.name "github-actions[bot]"
Expand All @@ -320,6 +364,34 @@ jobs:
git push origin "${tag}"
fi

# Asserted after the tag push on purpose: npm already holds the reviewed
# bytes (verified above), so a dist-tag propagation hiccup must not
# strand the release npm-published but origin-untagged. Retries until
# the tag points at THIS version — a stale packument still echoing the
# previous version is a retryable state, not a terminal mismatch.
- name: Verify the npm dist-tag
run: |
set -euo pipefail
npm_err="$RUNNER_TEMP/npm-view-err.log"
tagged=""
for delay in 0 2 3 5 8 13; do
sleep "$delay"
tagged="$(npm view --prefer-online "@supabase/config" "dist-tags.${NPM_TAG}" 2>"$npm_err")" || tagged=""
if [[ "$tagged" == "$VERSION" ]]; then
break
fi
done
if [[ "$tagged" != "$VERSION" ]]; then
echo "dist-tag '${NPM_TAG}' points at ${tagged:-nothing}, expected ${VERSION}." >&2
echo "If a newer release has since moved the tag, this is a stale re-run rather than registry corruption." >&2
if [[ -s "$npm_err" ]]; then
echo "Last npm error output:" >&2
cat "$npm_err" >&2
fi
exit 1
fi
echo "dist-tag ${NPM_TAG} -> ${VERSION} confirmed."

- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
Expand All @@ -333,3 +405,111 @@ jobs:
# releases/latest/download/..., so a config release must never
# become the repo's "latest" release.
make_latest: "false"

# Pages the release Slack channel the moment a real run arms the
# config-release approval gate. This job only needs `plan`, so it runs in
# parallel with the publish job's `waiting` state — the ping and the pending
# deployment appear together. The approval itself stays on GitHub: the run
# page hosts the Approve button and the plan job's evidence summary; the
# webhook is one-way and cannot host an interactive approval. Nothing
# depends on this job, so a Slack/webhook failure can't block the release.
notify-slack-approval:
name: Notify Slack (approval needed)
needs: plan
if: needs.plan.outputs.should_release == 'true' && needs.plan.outputs.dry_run != 'true'
uses: ./.github/workflows/slack-notify.yml
with:
package: "@supabase/config"
status: awaiting-approval
version: ${{ needs.plan.outputs.version }}
tag_prefix: config-v
secrets:
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}

# Posts once the publish job has verified the release is registry-visible
# with the reviewed bytes. No `if:` needed: the implicit success() gate
# means this only runs when plan and publish both succeeded, and publish
# itself only runs for real (non-dry) releases — dry runs and no-release
# pushes skip publish, which skips this too. Nothing depends on this job,
# so a Slack/webhook failure can't affect the already-completed release.
notify-slack:
name: Notify Slack
needs: [plan, publish]
uses: ./.github/workflows/slack-notify.yml
with:
package: "@supabase/config"
status: success
version: ${{ needs.plan.outputs.version }}
tag_prefix: config-v
npm_package: "@supabase/config"
secrets:
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}

# Distinguishes "a reviewer rejected the pending deployment" from a real
# pipeline failure before paging the channel: a rejection marks the publish
# job failed, and announcing that as a broken release would page people
# about a deliberate decision. The run's approvals record is the only place
# the distinction is visible from inside the workflow. The dry-run guard
# reads the dispatch input directly rather than plan's output, so a plan
# job that dies before recording dry_run still can't page for an
# operator-watched dry run.
classify-failure:
name: Classify failure
needs: [plan, publish]
if: ${{ failure() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
runs-on: ubuntu-latest
timeout-minutes: 5
# The approvals endpoint needs actions: read, which the workflow-level
# `contents: read` block would otherwise zero out.
permissions:
actions: read
outputs:
status: ${{ steps.classify.outputs.status }}
steps:
- id: classify
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_RESULT: ${{ needs.publish.result }}
run: |
set -euo pipefail
# Fold ONLY a definite rejection into `declined`; an API error stays
# a plain failure so a broken release is never misreported as a
# calm "not approved". Two guards: the publish job itself must be
# the failed job (a rejection can only manifest there — a plan
# failure in a run whose earlier attempt was rejected is a plain
# failure), and the approvals record spans every attempt of this
# run in append order, so the LAST review is the operative
# decision — a rejection followed by a re-run and an approval is an
# approved deployment that failed for some other reason.
last_state=""
if [[ "$PUBLISH_RESULT" == "failure" ]]; then
if approvals="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/approvals" 2>/dev/null)"; then
last_state="$(jq -r 'if type == "array" and length > 0 then .[-1].state else "" end' <<<"$approvals")"
fi
fi
if [[ "$last_state" == "rejected" ]]; then
echo "status=declined" >> "$GITHUB_OUTPUT"
else
echo "status=failure" >> "$GITHUB_OUTPUT"
fi

# Reports a failed (or reviewer-declined) release. `failure()` on the
# classify job evaluates against its `needs` chain, so this pair fires
# whenever `plan` or `publish` fails — including an approval rejection —
# but stays quiet for dry runs and no-release pushes (skipped needs don't
# count as failures). When `plan` fails its outputs are empty, so the
# message falls back to the workflow run link as the actionable detail.
# Fails open: if the classifier itself breaks, the page still goes out as a
# plain failure (its status output is empty, so the expression falls back).
notify-slack-failure:
name: Notify Slack (failure)
needs: [plan, publish, classify-failure]
if: ${{ !cancelled() && needs.classify-failure.result != 'skipped' }}
uses: ./.github/workflows/slack-notify.yml
with:
package: "@supabase/config"
status: ${{ needs.classify-failure.outputs.status || 'failure' }}
version: ${{ needs.plan.outputs.version }}
tag_prefix: config-v
secrets:
SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }}
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ jobs:
needs.plan.outputs.channel == 'stable'
uses: ./.github/workflows/slack-notify.yml
with:
package: Supabase CLI
status: success
version: ${{ needs.plan.outputs.version }}
channel: ${{ needs.plan.outputs.channel }}
Expand All @@ -307,15 +308,18 @@ jobs:
# `needs` chain, so this fires whenever `plan` or `release` (and anything in
# the reusable release-shared workflow) fails. Skipped jobs — e.g. the
# fast-forward path or a release that never started — don't count as failures,
# so this stays quiet there. Dry runs are excluded; an operator running one is
# already watching it live. When `plan` fails its outputs are empty, so the
# message falls back to the workflow run link as the actionable detail.
# so this stays quiet there. Dry runs are excluded — the guard reads the
# dispatch input directly, so even a plan job that dies before recording its
# dry_run output stays quiet; an operator running one is already watching it
# live. When `plan` fails its outputs are empty, so the message falls back to
# the workflow run link as the actionable detail.
notify-slack-failure:
name: Notify Slack (failure)
needs: [plan, release]
if: failure() && needs.plan.outputs.dry_run != 'true'
if: ${{ failure() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
uses: ./.github/workflows/slack-notify.yml
with:
package: Supabase CLI
status: failure
version: ${{ needs.plan.outputs.version }}
channel: ${{ needs.plan.outputs.channel }}
Expand Down
Loading
Loading