Repository navigation
feat(ui): native-text BibleReader body and footnotes #144
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: 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 |