Skip to content

feat(ui): native-text BibleReader body and footnotes #144

feat(ui): native-text BibleReader body and footnotes

feat(ui): native-text BibleReader body and footnotes #144

name: Major Release Signoff
on:
pull_request:
# `edited` covers a retarget: the same head evaluated against a feature base can be
# green, and without re-evaluating, that result would stand once the base becomes main.
types: [opened, synchronize, reopened, edited]
issue_comment:
# `deleted` ensures the gate re-runs when a signoff comment is removed,
# so a previously-cached `success` status doesn't outlive its evidence.
types: [created, edited, deleted]
# Declared per job. The job that runs PR-authored code holds no write scopes, and the
# job that can write statuses and comments never checks that code out.
permissions: {}
concurrency:
# Workflow concurrency is resolved before job `if` conditions. Isolate bot comment
# runs so comments from changeset-bot or this workflow cannot cancel an active PR
# evaluation before their jobs are skipped.
group: major-release-signoff-${{ github.event.pull_request.number || github.event.issue.number }}-${{ github.event_name == 'issue_comment' && github.event.comment.user.type == 'Bot' && format('bot-{0}', github.run_id) || 'evaluation' }}
cancel-in-progress: true
env:
STATUS_CONTEXT: major-release-signoff
jobs:
context:
# issue_comment fires for issues AND PRs across the repo. Skip non-PR
# issues and bot comments. Bot runs have isolated concurrency keys above,
# because this condition is not evaluated until after concurrency handling.
if: ${{ github.event_name != 'issue_comment' || (github.event.issue.pull_request != null && github.event.comment.user.type != 'Bot') }}
name: Resolve PR context
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
# For the retarget hold below. Resolving context needs no write scope itself.
statuses: write
outputs:
base_ref: ${{ steps.pr.outputs.base_ref }}
base_sha: ${{ steps.pr.outputs.base_sha }}
status_context: ${{ steps.pr.outputs.status_context }}
generated_candidate: ${{ steps.pr.outputs.generated_candidate }}
generated_release_pr: ${{ steps.pr.outputs.generated_release_pr }}
head_sha: ${{ steps.pr.outputs.head_sha }}
is_fork: ${{ steps.pr.outputs.is_fork }}
pr_author: ${{ steps.pr.outputs.pr_author }}
pr_number: ${{ steps.pr.outputs.pr_number }}
steps:
# A retarget changes which context this PR is judged on, and the status written under
# the old base stays on the SHA until a fresh one replaces it. A PR green while
# targeting main, moved to a feature branch, then moved back, has a stale unsuffixed
# success standing for the whole re-evaluation. Hold it pending first, so a required
# check cannot be satisfied by the old answer while the new one is computed.
#
# A step rather than its own job: as a separate job it needed `needs` to avoid racing
# the evaluation, and that dependency propagated its skip to `preview` on every
# ordinary event, which made the gate report unknown impact on normal pushes.
- name: Mark the status pending
if: ${{ github.event_name == 'pull_request' && github.event.action == 'edited' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
REPOSITORY: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# This PR's own context, so a shared head SHA on another PR is untouched.
CTX="$STATUS_CONTEXT"
if [ "$BASE_REF" != "main" ]; then
CTX="$STATUS_CONTEXT/$BASE_REF"
fi
gh api "repos/$REPOSITORY/statuses/$HEAD_SHA" \
-f state=pending \
-f context="$CTX" \
-f description="Base branch changed; re-evaluating release impact." \
-f target_url="$RUN_URL" > /dev/null
echo "Held $CTX pending while the retarget is re-evaluated."
- name: Resolve PR context
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
PR_NUMBER=${EVENT_PR_NUMBER:-$EVENT_ISSUE_NUMBER}
# Resolve every event through the API. In particular, issue_comment payloads
# contain no PR head, and created/edited/deleted comments must evaluate the
# current immutable head rather than the head from an earlier workflow run.
# Retry rather than die on the first blip. Everything downstream needs the head
# SHA this call returns, and on issue_comment there is no payload fallback, so a
# transient API failure here would otherwise leave the status already on the SHA
# untouched -- including a success whose signoff has since been revoked.
PR_JSON=""
for attempt in 1 2 3; do
if PR_JSON=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER"); then
break
fi
PR_JSON=""
echo "::warning::Could not read PR $PR_NUMBER (attempt $attempt of 3)."
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 3))
fi
done
if [ -z "$PR_JSON" ]; then
echo "::error::Could not read PR $PR_NUMBER after 3 attempts."
exit 1
fi
HEAD_SHA=$(jq -r '.head.sha // empty' <<<"$PR_JSON")
if ! [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve a full PR head SHA."
exit 1
fi
BASE_SHA=$(jq -r '.base.sha // empty' <<<"$PR_JSON")
PR_AUTHOR=$(jq -r '.user.login // empty' <<<"$PR_JSON")
PR_AUTHOR_ID=$(jq -r '.user.id // empty' <<<"$PR_JSON")
PR_AUTHOR_TYPE=$(jq -r '.user.type // empty' <<<"$PR_JSON")
HEAD_REPO=$(jq -r '.head.repo.full_name // empty' <<<"$PR_JSON")
HEAD_REF=$(jq -r '.head.ref // empty' <<<"$PR_JSON")
BASE_REPO=$(jq -r '.base.repo.full_name // empty' <<<"$PR_JSON")
BASE_REF=$(jq -r '.base.ref // empty' <<<"$PR_JSON")
# A deleted fork reports no head repo. Treat that as a fork too, since we
# cannot show it came from here.
if [ "$HEAD_REPO" = "$REPOSITORY" ]; then
IS_FORK=false
else
IS_FORK=true
fi
GENERATED_CANDIDATE=false
GENERATED_RELEASE_PR=false
if [ "$HEAD_REPO" = "$REPOSITORY" ] && \
[ "$BASE_REPO" = "$REPOSITORY" ] && \
[ "$BASE_REF" = "main" ] && \
[ "$HEAD_REF" = "changeset-release/main" ] && \
[ "$PR_AUTHOR" = 'github-actions[bot]' ] && \
[ "$PR_AUTHOR_ID" = "41898282" ] && \
[ "$PR_AUTHOR_TYPE" = "Bot" ]; then
GENERATED_RELEASE_PR=true
# Bot authorship does not authenticate later commits pushed to the branch.
# Bind the exception to this immutable base/head pair and only accept the
# release lifecycle shape: one or more consumed changeset Markdown files,
# with no added, modified, renamed, or copied changeset input. The complete
# generated contents are verified separately with base-owned tooling below.
if ! [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::warning::Could not resolve a full PR base SHA for generated release verification."
elif COMPARE_JSON=$(gh api "repos/$REPOSITORY/compare/$BASE_SHA...$HEAD_SHA"); then
if jq -e --arg base "$BASE_SHA" '
(.base_commit.sha == $base) and
(.files | type == "array") and
((.files | length) < 300) and
([.files[] | select(.filename | startswith(".changeset/"))] as $changesets |
($changesets | length) > 0 and
all($changesets[];
.status == "removed" and
(.filename | test("^\\.changeset/[^/]+\\.md$"))))
' >/dev/null <<<"$COMPARE_JSON"; then
GENERATED_CANDIDATE=true
else
echo "::warning::Generated release comparison was incomplete, ambiguous, or contained non-deletion changeset input."
fi
else
echo "::warning::Could not verify the generated release PR comparison."
fi
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT"
# A commit status is keyed on SHA + context, and GitHub keeps only the newest per
# pair. Two PRs can share a head: `H -> feature` inherits that branch's major and
# passes, `H -> main` introduces one and must not. Publishing both to the same
# context lets the permissive result satisfy the strict one. Only a main-targeted
# evaluation writes the required context; anything else reports beside it.
if [ "$BASE_REF" = "main" ]; then
echo "status_context=$STATUS_CONTEXT" >> "$GITHUB_OUTPUT"
else
echo "status_context=$STATUS_CONTEXT/$BASE_REF" >> "$GITHUB_OUTPUT"
fi
echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
echo "is_fork=$IS_FORK" >> "$GITHUB_OUTPUT"
echo "generated_candidate=$GENERATED_CANDIDATE" >> "$GITHUB_OUTPUT"
echo "generated_release_pr=$GENERATED_RELEASE_PR" >> "$GITHUB_OUTPUT"
generated_release:
# The metadata and comparison shape are a cheap fail-closed prefilter. Prove the
# contents separately by reproducing Changesets' output from the immutable base.
if: needs.context.outputs.generated_candidate == 'true'
name: Verify generated release contents
needs: context
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
verified: ${{ steps.verify.outputs.verified }}
steps:
- name: Checkout immutable base
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.context.outputs.base_sha }}
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: 'package.json'
cache: 'pnpm'
- name: Install base-owned release tooling
run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
- name: Regenerate and verify release contents
id: verify
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
run: |
set -euo pipefail
git fetch --no-tags --force origin "$HEAD_SHA"
git cat-file -e "$HEAD_SHA^{commit}"
pnpm version-packages
# Compare complete Git trees, not allowed paths or patches. This proves that
# base-owned Changesets tooling generated every byte at the evaluated head.
git add -A
GENERATED_TREE=$(git write-tree)
HEAD_TREE=$(git rev-parse "$HEAD_SHA^{tree}")
if [ "$GENERATED_TREE" = "$HEAD_TREE" ]; then
echo "verified=true" >> "$GITHUB_OUTPUT"
else
echo "verified=false" >> "$GITHUB_OUTPUT"
echo "::error::Release PR contents do not exactly match base-owned Changesets output."
fi
preview:
# Skipped for forks. A fork's code is untrusted, so rather than run it we fail closed
# in the gate below and report that its release impact could not be determined. Any
# PR with the exact generated-release identity stays out of the ordinary preview,
# even when its comparison is unverifiable, so ambiguity cannot bypass verification.
if: needs.context.outputs.is_fork != 'true' && needs.context.outputs.generated_release_pr != 'true'
name: Compute release preview
needs: context
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
is_major: ${{ steps.preview.outputs.is_major }}
next: ${{ steps.preview.outputs.next }}
release_type: ${{ steps.preview.outputs.release_type }}
steps:
- name: Checkout PR head
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.context.outputs.head_sha }}
fetch-depth: 0
- name: Restore release tooling from the base branch
# The PR supplies changeset *data* only. The detector that reads it, the dependency
# set it runs under, and the changesets config all come from `main`, so a branch
# cannot rewrite the check that gates it. This runs before pnpm is set up, since
# `pnpm/action-setup` reads `packageManager` out of package.json.
run: |
set -euo pipefail
git fetch --no-tags --force origin main:refs/heads/main
# All of main's tooling, or none of it. The detector, the dependency set it runs
# under, and the changesets config have to agree, so restoring some from main and
# taking the rest from the PR gives a detector without its own dependencies.
if git cat-file -e main:scripts/preview-release.mjs 2>/dev/null; then
# Frozen workspace installs validate every importer. Remove PR-only importers,
# then restore every manifest selected by main's workspace configuration so its
# lockfile and dependency graph remain internally consistent.
#
# This also closes a hole in classification: the detector decides what is
# published by reading `private` out of each workspace manifest, and those came
# from the PR. A branch could mark a package private to hide its own major, then
# restore publication later without a new changeset.
# Every manifest, not just workspace ones. Node resolves a bare specifier by
# walking up from the importing file, so a PR-added `scripts/package.json`
# naming itself `@changesets/parse` with its own `exports` is loaded by the
# restored detector instead of the real package, and the detector then reports
# whatever that module returns. Anything tracked under a `node_modules`
# directory is the same trick with a different shape.
git ls-files |
grep -E '(^|/)package[.]json$|(^|/)node_modules/' |
while IFS= read -r f; do
git cat-file -e "main:$f" 2>/dev/null || rm -f "$f"
done
for f in scripts/preview-release.mjs scripts/changeset-eligibility.mjs \
package.json pnpm-lock.yaml \
pnpm-workspace.yaml .changeset/config.json; do
git cat-file -e "main:$f" 2>/dev/null && git checkout main -- "$f"
done
git ls-tree -r --name-only main |
grep -E '^(packages|apps)/[^/]+/package[.]json$' |
while IFS= read -r f; do
git checkout main -- "$f"
done
# Install hooks a PR could add to rewrite what we just restored. Take main's, or
# remove it outright: leaving a PR-authored one hands back the control.
for f in .npmrc .pnpmfile.cjs; do
if git cat-file -e "main:$f" 2>/dev/null; then
git checkout main -- "$f"
else
rm -f "$f"
fi
done
else
echo "::warning::scripts/preview-release.mjs is not on main yet, so this PR supplies its own tooling. The trust boundary engages once it merges."
fi
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: 'package.json'
cache: 'pnpm'
- name: Install dependencies
# `--ignore-scripts` stops lifecycle scripts; `--ignore-pnpmfile` stops pnpm's
# own hooks, which are a separate mechanism and can rewrite files on disk. The
# job holds no write scopes either, so nothing here reaches the status API.
run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
- name: Compute release preview
id: preview
env:
BASE_SHA: ${{ needs.context.outputs.base_sha }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
run: |
set -euo pipefail
# `changeset status` resolves `baseBranch` (main) as a LOCAL ref and fails with
# "Failed to find where HEAD diverged from main" if it is missing. A PR checkout
# has only origin/main, so create the local branch too.
git fetch --no-tags --force origin main:refs/heads/main
git fetch --no-tags --force origin "$BASE_SHA"
# Compare from the common ancestor of the immutable PR endpoints. This excludes
# major changesets inherited from a stacked PR's base without attributing later
# movement on that base branch to the PR being evaluated.
BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
node scripts/preview-release.mjs --base "$BASE" --head "$HEAD_SHA" > preview.json
cat preview.json
# `introduced_major`, not `is_major`: a major already pending on main
# must not gate every unrelated PR until the release goes out. Only the
# PR that adds the breaking changeset needs a signoff.
INTRODUCED=$(jq -r .introduced_major preview.json)
case "$INTRODUCED" in
true) IS_MAJOR=1 ;;
*) IS_MAJOR=0 ;;
esac
echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT"
# `// empty` rather than plain `-r`: jq prints a JSON null as the literal string
# "null", which is not empty, so the `${VAR:-default}` fallbacks downstream never
# fire and the status reads "No breaking changeset added (null)".
echo "next=$(jq -r '.next // empty' preview.json)" >> "$GITHUB_OUTPUT"
echo "release_type=$(jq -r '.release_type // empty' preview.json)" >> "$GITHUB_OUTPUT"
# `context` is what resolves the head SHA, so when it fails the gate below is skipped
# and whatever status was last written to that SHA stands -- including a `success` whose
# signoff comment has since been edited or deleted. Write a failing status so a revocation
# can never leave a stale green on a major release.
#
# The head SHA has to come from somewhere other than the API call that just failed.
# `pull_request` payloads carry it; `issue_comment` payloads do not, so read
# `refs/pull/N/head` over git, which is a different backend from the REST call in
# `context` and needs no scope beyond `contents: read`.
#
# `failure`, not `!= 'success'`: a cancelled run has been superseded by a newer one, and
# painting the PR red for that is noise.
context_unresolved:
if: always() && !cancelled() && needs.context.result == 'failure'
name: Signoff status when context is unresolved
needs: context
runs-on: ubuntu-latest
permissions:
contents: read
statuses: write
env:
RESOLVED_STATUS_CONTEXT: ${{ needs.context.outputs.status_context }}
steps:
- name: Post an unresolved status
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
PAYLOAD_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PAYLOAD_BASE_REF: ${{ github.event.pull_request.base.ref }}
REPOSITORY: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# `context` failed, so its resolved context is empty. A pull_request payload still
# carries the base, and using it matters: two PRs can share a head, so writing this
# failure to the required context would red-flag a main-targeted PR because a
# feature-targeted one could not resolve. Where the base is unknowable, fall back
# to the required context rather than leave a stale green.
if [ -n "$PAYLOAD_BASE_REF" ] && [ "$PAYLOAD_BASE_REF" != "main" ]; then
RESOLVED_STATUS_CONTEXT="$STATUS_CONTEXT/$PAYLOAD_BASE_REF"
fi
HEAD_SHA="$PAYLOAD_HEAD_SHA"
if [ -z "$HEAD_SHA" ]; then
HEAD_SHA=$(git ls-remote \
"https://x-access-token:$GH_TOKEN@github.com/$REPOSITORY" \
"refs/pull/$EVENT_ISSUE_NUMBER/head" | cut -f1) || HEAD_SHA=""
fi
if ! [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::PR context is unresolved and no head SHA could be recovered."
exit 1
fi
gh api "repos/$REPOSITORY/statuses/$HEAD_SHA" \
-f state=failure \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="Could not resolve PR context; release impact is unknown." \
-f target_url="$RUN_URL" > /dev/null
echo "Posted a failing status: PR context could not be resolved."
exit 1
gate:
# `always()`: `preview` is skipped for forks and the gate still has to post a status.
if: always() && !cancelled() && needs.context.result == 'success'
name: Evaluate release signoff
needs: [context, generated_release, preview]
runs-on: ubuntu-latest
env:
RESOLVED_STATUS_CONTEXT: ${{ needs.context.outputs.status_context }}
permissions:
pull-requests: write
statuses: write
steps:
- name: Decide whether a signoff is required
id: decision
env:
GENERATED_CANDIDATE: ${{ needs.context.outputs.generated_candidate }}
GENERATED_RELEASE_PR: ${{ needs.context.outputs.generated_release_pr }}
GENERATED_RELEASE_RESULT: ${{ needs.generated_release.result }}
IS_FORK: ${{ needs.context.outputs.is_fork }}
PREVIEW_RESULT: ${{ needs.preview.result }}
PREVIEW_IS_MAJOR: ${{ needs.preview.outputs.is_major }}
PREVIEW_NEXT: ${{ needs.preview.outputs.next }}
PREVIEW_RELEASE_TYPE: ${{ needs.preview.outputs.release_type }}
VERIFIED_GENERATED_RELEASE: ${{ needs.generated_release.outputs.verified }}
run: |
set -euo pipefail
if [ "$GENERATED_RELEASE_PR" = "true" ]; then
echo "is_major=0" >> "$GITHUB_OUTPUT"
echo "next=" >> "$GITHUB_OUTPUT"
echo "release_type=" >> "$GITHUB_OUTPUT"
if [ "$GENERATED_CANDIDATE" != "true" ]; then
echo "blocked=generated release comparison could not be verified" >> "$GITHUB_OUTPUT"
echo "generated_release=false" >> "$GITHUB_OUTPUT"
elif [ "$GENERATED_RELEASE_RESULT" = "success" ] && \
[ "$VERIFIED_GENERATED_RELEASE" = "true" ]; then
echo "blocked=" >> "$GITHUB_OUTPUT"
echo "generated_release=true" >> "$GITHUB_OUTPUT"
else
echo "blocked=generated release contents did not match base-owned Changesets output" >> "$GITHUB_OUTPUT"
echo "generated_release=false" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
echo "generated_release=false" >> "$GITHUB_OUTPUT"
# Fail closed. A fork, or a preview that did not succeed, means release impact
# is unknown. Keep the gate red, but do not classify that uncertainty as a
# confirmed breaking change or offer signoff instructions for an unknown result.
if [ "$IS_FORK" = "true" ] || [ "$PREVIEW_RESULT" != "success" ]; then
if [ "$IS_FORK" = "true" ]; then
echo "blocked=pull request is from a fork, so its release preview is not trusted" >> "$GITHUB_OUTPUT"
else
echo "blocked=release preview did not succeed ($PREVIEW_RESULT)" >> "$GITHUB_OUTPUT"
fi
echo "is_major=0" >> "$GITHUB_OUTPUT"
echo "next=$PREVIEW_NEXT" >> "$GITHUB_OUTPUT"
echo "release_type=$PREVIEW_RELEASE_TYPE" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$PREVIEW_IS_MAJOR" in
0|1) ;;
*)
echo "blocked=release preview returned an invalid major decision" >> "$GITHUB_OUTPUT"
echo "is_major=0" >> "$GITHUB_OUTPUT"
echo "next=$PREVIEW_NEXT" >> "$GITHUB_OUTPUT"
echo "release_type=$PREVIEW_RELEASE_TYPE" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
# The version reaches a regex below, so anything that is not a plain semver
# string is recorded as unevaluable rather than aborting: the job must still
# reach the step that posts a red status.
if [ "$PREVIEW_IS_MAJOR" = "1" ] && \
! printf '%s' "$PREVIEW_NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Preview reported a major but next version '$PREVIEW_NEXT' is not a plain semver string."
echo "blocked=next version '$PREVIEW_NEXT' is not a plain semver string" >> "$GITHUB_OUTPUT"
echo "is_major=0" >> "$GITHUB_OUTPUT"
echo "next=" >> "$GITHUB_OUTPUT"
echo "release_type=$PREVIEW_RELEASE_TYPE" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "blocked=" >> "$GITHUB_OUTPUT"
echo "is_major=$PREVIEW_IS_MAJOR" >> "$GITHUB_OUTPUT"
echo "next=$PREVIEW_NEXT" >> "$GITHUB_OUTPUT"
echo "release_type=$PREVIEW_RELEASE_TYPE" >> "$GITHUB_OUTPUT"
- name: Remove stale workflow-owned signoff instructions
# Unknown impact must not leave a comment claiming a confirmed breaking change.
# Trusted non-major results also retire instructions from an earlier evaluation.
if: steps.decision.outputs.blocked != '' || steps.decision.outputs.is_major != '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.context.outputs.pr_number }}
run: |
set -euo pipefail
MARKER='<!-- major-signoff-required -->'
gh api --paginate \
"repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$MARKER\"))) | .id" |
while read -r COMMENT_ID; do
[ -z "$COMMENT_ID" ] && continue
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$COMMENT_ID"
echo "Removed stale blocking comment ($COMMENT_ID)."
done
- name: Mark PRs without a breaking change as success
if: steps.decision.outputs.blocked == '' && steps.decision.outputs.is_major != '1' && steps.decision.outputs.generated_release != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
RELEASE_TYPE: ${{ steps.decision.outputs.release_type }}
run: |
set -euo pipefail
gh api repos/${{ github.repository }}/statuses/$HEAD_SHA \
-f state=success \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="No breaking changeset added (${RELEASE_TYPE:-no bump}); signoff not required." \
-f target_url="$RUN_URL"
- name: Mark generated release PR as success
if: steps.decision.outputs.generated_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
gh api repos/${{ github.repository }}/statuses/$HEAD_SHA \
-f state=success \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="Generated release PR; major signoff is enforced on source PRs." \
-f target_url="$RUN_URL"
- name: Post failure status when release impact cannot be determined
# `always() && !cancelled()` rather than the implicit `success()`: this step follows
# the stale-comment cleanup, and a cleanup failure would otherwise skip it and leave
# an earlier green status authoritative on a decision we could not determine.
if: always() && !cancelled() && steps.decision.outputs.blocked != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BLOCKED: ${{ steps.decision.outputs.blocked }}
run: |
set -euo pipefail
gh api repos/${{ github.repository }}/statuses/$HEAD_SHA \
-f state=failure \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="Unable to determine release impact; inspect the workflow run." \
-f target_url="$RUN_URL"
echo "Unable to determine release impact: $BLOCKED"
- name: Search PR comments for valid approver signoff
id: signoff
if: steps.decision.outputs.is_major == '1' && steps.decision.outputs.blocked == ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.context.outputs.pr_number }}
PR_AUTHOR: ${{ needs.context.outputs.pr_author }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
NEXT_VERSION: ${{ steps.decision.outputs.next }}
run: |
set -euo pipefail
# Guard: a major changeset must also have produced a real `next`
# version. An empty or literal "null" value would degenerate the
# matcher regex into something that matches almost every comment.
# Fail loud instead of silently passing the gate.
if [ -z "$NEXT_VERSION" ] || [ "$NEXT_VERSION" = "null" ]; then
echo "::error::A major changeset was added but the next version is empty or null; aborting signoff check."
exit 1
fi
# Escape dots so the bash regex literal-matches them; an unescaped
# dot would match any single char.
ESCAPED_VERSION=${NEXT_VERSION//./\\.}
# Accept both `3.0.0` and `v3.0.0`. Word-boundary character classes
# prevent matching `3.0.00` or `vv3.0.0`.
VERSION_RE="(^|[^0-9A-Za-z])v?${ESCAPED_VERSION}([^0-9A-Za-z]|$)"
ROCKET_RE='(πŸš€|:rocket:)'
# The approver must quote this phrase verbatim: an active confirmation
# that the break is intentional and its impact is documented, not a
# passive "yes". Any deviation fails the match.
TEMPLATE_PHRASE='I confirm that this is an intentional breaking change, and I have read the release procedures. I understand and have documented its impact upon release.'
# Normalize: drop `>` quote markers and collapse whitespace, so GitHub's
# "Quote reply", hard-wrapped lines, and CRLF-vs-LF all match.
normalize() {
printf '%s' "$1" | tr -d '>' | tr -s '[:space:]' ' '
}
NORM_TEMPLATE=$(normalize "$TEMPLATE_PHRASE")
# A signoff approves the code it was given on, so it names that code. Comparing
# timestamps instead would trust `committer.date`, which the committer sets, so a
# backdated commit could inherit an older approval. A SHA cannot be backdated, and
# a new push changes it, which retires the previous signoff on its own.
# The full 40 characters, not an abbreviation: a 7-character prefix is
# 28 bits, which is cheap to brute-force, so an author could craft a new
# commit sharing the approved prefix and inherit its signoff.
SHA_RE="(^|[^0-9A-Za-z])${HEAD_SHA}([^0-9A-Za-z]|$)"
echo "Signoff must name head $HEAD_SHA."
COMMENTS=$(gh api --paginate \
"repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq '[.[] | {login: .user.login, type: .user.type, body: .body, html_url: .html_url}]')
declare -A PERM_CACHE
MATCH_LOGIN=""
MATCH_URL=""
while IFS= read -r row; do
LOGIN=$(jq -r .login <<<"$row")
BODY=$(jq -r .body <<<"$row")
URL=$(jq -r .html_url <<<"$row")
USER_TYPE=$(jq -r .type <<<"$row")
# Bots can't sign off.
if [ "$USER_TYPE" = "Bot" ]; then continue; fi
# Skip the PR author β€” the point of the gate is a second pair of
# eyes. An author with write access could otherwise clear it alone.
if [ "$LOGIN" = "$PR_AUTHOR" ]; then continue; fi
# Content match first: cheap, and most comments won't qualify.
NORM_BODY=$(normalize "$BODY")
if [[ "$NORM_BODY" != *"$NORM_TEMPLATE"* ]]; then continue; fi
if ! [[ "$BODY" =~ $VERSION_RE ]] || ! [[ "$BODY" =~ $ROCKET_RE ]]; then continue; fi
if ! [[ "$BODY" =~ $SHA_RE ]]; then continue; fi
# Then verify write+ permission. Cached so a chatty approver doesn't
# cost one API call per comment.
PERM="${PERM_CACHE[$LOGIN]:-}"
if [ -z "$PERM" ]; then
PERM=$(gh api "repos/${{ github.repository }}/collaborators/$LOGIN/permission" \
--jq '.permission' 2>/dev/null || echo "none")
PERM_CACHE[$LOGIN]="$PERM"
fi
case "$PERM" in
admin|maintain|write) MATCH_LOGIN="$LOGIN"; MATCH_URL="$URL"; break ;;
esac
done < <(jq -c '.[]' <<<"$COMMENTS")
if [ -n "$MATCH_LOGIN" ]; then
echo "signed_off=1" >> "$GITHUB_OUTPUT"
echo "approver=$MATCH_LOGIN" >> "$GITHUB_OUTPUT"
echo "Signoff found: $MATCH_LOGIN ($MATCH_URL)"
else
echo "signed_off=0" >> "$GITHUB_OUTPUT"
echo "No qualifying signoff found yet."
fi
- name: Post success status when signoff present
if: steps.decision.outputs.blocked == '' && steps.decision.outputs.is_major == '1' && steps.signoff.outputs.signed_off == '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
APPROVER: ${{ steps.signoff.outputs.approver }}
NEXT_VERSION: ${{ steps.decision.outputs.next }}
run: |
set -euo pipefail
gh api repos/${{ github.repository }}/statuses/$HEAD_SHA \
-f state=success \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="Major v${NEXT_VERSION} signed off by ${APPROVER}." \
-f target_url="$RUN_URL"
- name: Upsert blocking comment when signoff missing
if: always() && !cancelled() && steps.decision.outputs.blocked == '' && steps.decision.outputs.is_major == '1' && steps.signoff.outputs.signed_off != '1' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.context.outputs.pr_number }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
NEXT_VERSION: ${{ steps.decision.outputs.next }}
run: |
set -euo pipefail
MARKER='<!-- major-signoff-required -->'
# echo-per-line rather than a heredoc: un-indented heredoc lines
# confuse the YAML block-scalar parser.
{
echo "$MARKER"
echo "## 🚨 Breaking change detected β€” signoff required for \`v$NEXT_VERSION\`"
echo
echo "This PR adds a changeset declaring a **major** bump, which would ship both packages as \`v$NEXT_VERSION\` (they are a \`fixed\` group in \`.changeset/config.json\`, so they version together). Merging is blocked until a repo collaborator with write access comments on this PR with **all four (4) of the following:**"
echo
echo "1. The verbatim acknowledgment phrase below,"
echo "2. The precise next version (\`v$NEXT_VERSION\` or \`$NEXT_VERSION\`),"
echo "3. The full commit hash being approved (\`$HEAD_SHA\`), and"
echo "4. A πŸš€ (\`:rocket:\`) emoji."
echo
echo "I confirm that this is an intentional breaking change, and I have read the release procedures. I understand and have documented its impact upon release."
echo
echo "**Copy-paste-ready reply:**"
echo
echo '```'
echo "I confirm that this is an intentional breaking change, and I have read the release procedures. I understand and have documented its impact upon release."
echo
echo "v$NEXT_VERSION $HEAD_SHA πŸš€"
echo '```'
echo
echo "The check re-runs automatically when a qualifying comment is posted or edited. Pushing new commits changes the hash, so a fresh signoff is needed."
} > comment.md
EXISTING=$(gh api --paginate \
"repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq "[.[] | select(.body | contains(\"$MARKER\"))] | .[0].id // empty")
# JSON payload via --input: the body has newlines and backticks that
# don't survive -f string-encoding.
jq -Rs '{body: .}' < comment.md > comment.json
if [ -n "$EXISTING" ]; then
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$EXISTING" --input comment.json > /dev/null
echo "Updated existing blocking comment ($EXISTING)."
else
gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" --input comment.json > /dev/null
echo "Posted new blocking comment."
fi
- name: Post failure status when signoff missing
if: always() && !cancelled() && steps.decision.outputs.blocked == '' && steps.decision.outputs.is_major == '1' && steps.signoff.outputs.signed_off != '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ needs.context.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
NEXT_VERSION: ${{ steps.decision.outputs.next }}
run: |
set -euo pipefail
gh api repos/${{ github.repository }}/statuses/$HEAD_SHA \
-f state=failure \
-f context="${RESOLVED_STATUS_CONTEXT:-$STATUS_CONTEXT}" \
-f description="Breaking change detected; awaiting v${NEXT_VERSION} + :rocket: signoff from a write-access collaborator." \
-f target_url="$RUN_URL"
- name: Fail the job so the gate is loud in the PR UI
if: always() && !cancelled() && ((steps.decision.outputs.blocked != '') || (steps.decision.outputs.is_major == '1' && steps.signoff.outputs.signed_off != '1'))
env:
BLOCKED: ${{ steps.decision.outputs.blocked }}
run: |
if [ -n "$BLOCKED" ]; then
echo "Unable to determine release impact: $BLOCKED"
else
echo "Breaking change detected; awaiting write-access collaborator signoff."
fi
exit 1