Skip to content

feat: read a narrowed question back off the stored summaries #100

feat: read a narrowed question back off the stored summaries

feat: read a narrowed question back off the stored summaries #100

Workflow file for this run

---
name: PR
on:
pull_request:
branches:
- main
# release.yml opens a version pull request from a `release/**` branch using
# GITHUB_TOKEN, and two things follow from that. Both were observed on a
# sibling repository running this same pair of workflows. Worth re-checking
# on this one's first release, because an earlier version of this comment
# reasoned about it from first principles and got it wrong.
#
# Its *push* starts no run at all, because GitHub will not let work done with
# GITHUB_TOKEN start another workflow. So the trigger below never fires for
# release.yml's own push. It is kept for a person pushing a fixup to one of
# those branches, which does fire, and which would otherwise get no checks.
#
# Its *pull request* does create a run of this workflow — but with
# `github-actions[bot]` as the actor, GitHub holds it as `action_required`
# until someone approves it from the Actions tab. So main's required checks
# do report on the version pull request, at the cost of one approval click
# per release. GitHub exposes no repository setting to waive that, and a
# release is already a deliberate act, so it is left as is.
push:
branches:
- "release/**"
defaults:
run:
shell: bash
# Only the head of a branch is worth checking. Pushing a fixup while the
# previous commit is still running abandons that run instead of queueing
# behind it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CI: true
permissions:
contents: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6.0.10
- uses: actions/setup-node@v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# `lint:ci` is `lint` with oxlint's GitHub formatter, which annotates the
# diff itself so a failure is readable where the change is rather than
# only in the log.
- run: pnpm lint:ci
- run: pnpm fta
# rainlytics.com builds its pages out of docs/ here. A page that breaks
# its scaffold breaks it in that repo, at deploy time, long after the
# change responsible. Cheaper to find it on the pull request that wrote
# it. See scripts/sh/docs-check.sh for the contract.
- run: pnpm docs:check
# Two runtimes: the floor `engines` claims, and the version .nvmrc develops
# against. A floor nothing ever runs is a claim, and the older half of this
# matrix is what turns it into a fact.
#
# The floor is Node 22 because that is what this package gets installed
# into. A consumer's CDK app runs on whatever Node they have, and the Lambda
# functions here deploy onto a runtime AWS provides. Both lag the current
# release by a good while. `@types/node` is pinned to the same major for the
# same reason, so a Node 26 API is a type error rather than a surprise at
# someone else's deploy time.
#
# These report as "Test (Node 22.x)" and "Test (Node 26.x)". Both belong in
# main's ruleset. See item 5 in release.yml's setup notes.
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
# One runtime failing says something specific about that runtime, and
# cancelling the other throws away the half of the answer that says
# which.
fail-fast: false
matrix:
node: ["22.x", "26.x"]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6.0.10
- uses: actions/setup-node@v7.0.0
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6.0.10
- uses: actions/setup-node@v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:check
- run: pnpm build
# What gets published is the tarball, not the working tree, and the two
# differ in ways nothing else here would catch. It rides along with the
# build rather than taking a runner of its own: `pnpm pack` runs the
# build again anyway, and a job spends far longer installing than either
# of these takes.
- run: pnpm pack:check
# The subject that lands on main, which is the release note.
#
# These merge as squashes, so a subject here becomes a commit subject on main,
# and that subject is both what semantic-release reads to decide the version
# and — since there is no CHANGELOG.md — the line a reader of the release
# sees. One that does not parse as a conventional commit is therefore two
# failures at once, and both are silent: the change releases nothing, and it
# appears in no notes. Nothing downstream can report that, because "no
# release" and "nothing releasable happened" look the same from there.
#
# So it is asked here, while the change is still in front of its author and
# one edit away. The types are read out of .releaserc.yaml rather than
# repeated, so this cannot drift from what actually publishes; the ones that
# release nothing are listed because a subject still has to parse to be a
# legal one, whether or not it ships.
#
# Which string will land is a repository setting rather than something this
# file can assume, so the step below asks rather than guessing. See item 4 in
# release.yml's setup notes.
#
# Deliberately not proposed as one of main's required checks. The others say
# the code works, which is a different kind of claim, and whether a badly
# titled pull request should be blocked or only embarrassed is a decision for
# the settings page rather than for this file.
title:
name: Title
# The `release/**` push above is release.yml's own version pull request,
# which has no `pull_request` payload to read.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
# Also covers reading the head commit's subject, below.
contents: read
# Listing the pull request's commits, which is what the squash body is
# made of under this repository's merge settings. Their footers land on
# main even though their subjects do not.
pull-requests: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check the subject that will land on main
env:
# Through the environment rather than interpolated into the script.
# A title is text anyone opening a pull request chooses, and `${{ }}`
# pastes it in as shell source before bash ever sees it.
TITLE: ${{ github.event.pull_request.title }}
# Both of these come from the same event payload, so they describe the
# same revision of the pull request. Counting commits from the payload
# and then fetching them fresh would not: a push between the event and
# this job would have it read one commit out of a branch that now has
# two, and check a subject that is no longer the one landing.
COMMIT_COUNT: ${{ github.event.pull_request.commits }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# For the breaking-change check below, which has to read whatever
# becomes the squash body. Through the environment for the same
# reason as the title: both are text an author chooses.
BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Which string GitHub will actually use as the squash subject is a
# repository setting, not something this file can assume. Under
# PR_TITLE it is always the pull request title; under GitHub's default
# of COMMIT_OR_PR_TITLE a single-commit pull request squashes with
# that commit's subject instead, and checking the title would then be
# checking a string that never reaches main. So ask.
setting="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.squash_merge_commit_title')"
if [[ "$setting" == "COMMIT_OR_PR_TITLE" && "$COMMIT_COUNT" == "1" ]]; then
# The subject is taken inside jq rather than by piping to `head`,
# which would close the pipe under `gh` and, with `pipefail` set,
# fail the step on a SIGPIPE that means nothing went wrong.
landing="$(
gh api "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" \
--jq '.commit.message | split("\n")[0]'
)"
source="the commit subject, which is what a one-commit squash uses"
else
landing="$TITLE"
source="the pull request title"
fi
# The types under `releaseRules:` in .releaserc.yaml. Anchored to
# that block, because the notes generator further down the file lists
# its own `- type:` entries and they are a different question.
releasing="$(
awk '
/^ releaseRules:/ { inside = 1; next }
inside && !/^ / { exit }
inside && $1 == "-" && $2 == "type:" { print $3 }
' .releaserc.yaml | paste -sd '|' -
)"
# The rest of the conventional set. These publish nothing, so a
# change titled with one of them says nothing in the notes either —
# which is the point of them, not a problem with them.
silent="docs|test|chore|ci|style|revert"
# The header pattern conventional-commits-parser actually uses, with
# the type narrowed to the ones this repository recognises.
subject="^(${releasing}|${silent})(\([^)]+\))?!?: .+"
if [[ ! "$landing" =~ $subject ]]; then
echo "::error::\"$landing\" is not a conventional commit subject. This is ${source}, so it becomes the commit message on main and the line in the release notes, and a subject that does not parse releases nothing and appears nowhere. Expected one of ${releasing}|${silent}, optionally scoped, then ': ' and a description."
exit 1
fi
echo "\"$landing\" parses (${source})."
# Conventional commits mark a breaking change with a `!` in the
# header or a `BREAKING CHANGE:` footer, and the pattern above
# tolerates the `!` because the spec does. This repository does not.
#
# `.releaserc.yaml` refuses to publish a major — that is a decision
# about what consumers have to rewrite, made by a person publishing
# by hand — so a breaking marker does not produce a major release. It
# produces a release run that stops, after the change is already on
# main, with nothing published and no way to tell from the outside
# whether that was the marker or an ordinary quiet release.
#
# Which is the same argument that put the subject check here rather
# than at release time: it is one edit away while the change is still
# in front of its author, and a fortnight of archaeology afterwards.
if [[ "$landing" =~ ^[a-z]+(\([^\)]*\))?! ]]; then
echo "::error::\"$landing\" carries a breaking-change marker. Major versions are published by hand here, so the release run refuses one rather than shipping it: this would land on main and then stop the next release. Drop the '!' and say what breaks in the body, or take it up as a hand-published major."
exit 1
fi
# The footer reaches the analyzer by the other route, and only if it
# lands in the squash body — which is a repository setting, like the
# subject above, so ask rather than assume.
#
# The ask often comes back empty, and that is the case worth getting
# right. GitHub only puts the merge settings in this payload for a
# token with admin rights, and `github.token` has none: the first run
# of this check reported "the squash body is blank" on a repository
# set to COMMIT_MESSAGES. So an unreadable setting must mean *check
# everything*, not *check nothing* — a guard that silently passes is
# worse than no guard, because it reads like one that ran.
message_setting="$(
gh api "repos/$GITHUB_REPOSITORY" \
--jq '.squash_merge_commit_message // empty' 2>/dev/null || true
)"
case "$message_setting" in
PR_BODY)
landing_body="$BODY"
body_source="the pull request body, which becomes the squash body"
;;
COMMIT_MESSAGES)
# Every commit's whole message is concatenated into the squash
# body, so a footer in any one of them reaches main even though
# its subject does not.
landing_body="$(
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \
--paginate --jq '.[].commit.message'
)"
body_source="the commit messages, which become the squash body"
;;
BLANK)
# Nothing carries a footer to main, so there is nothing here to
# refuse. Only trusted when the setting was actually readable.
landing_body=""
body_source="nothing — this repository squashes with a blank body"
;;
*)
# Unreadable, or a setting GitHub adds later. Check both of the
# places a footer could come from, since either might be the one
# that lands. The template asks for no footer anywhere, so this
# refuses nothing it does not already ask authors to avoid.
# Both, each starting at a line of its own: the pattern below is
# anchored, so a footer indented by this file's own layout would
# slip past it.
landing_body="$(
printf '%s\n' "$BODY"
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" \
--paginate --jq '.[].commit.message'
)"
body_source="the pull request body or the commit messages (the merge setting is not readable from here, so both are checked)"
;;
esac
# The two keywords conventional-commits-parser treats as notes, at
# the start of a line, which is where a footer is.
if grep -qE '^BREAKING[ -]CHANGE:' <<<"$landing_body"; then
echo "::error::A BREAKING CHANGE: footer is in ${body_source}. Major versions are published by hand here, so the release run refuses one rather than shipping it: this would land on main and then stop the next release. Say what breaks without the footer keyword, or take it up as a hand-published major."
exit 1
fi
echo "No breaking-change marker (checked ${body_source})."