feat: read a narrowed question back off the stored summaries #100
Workflow file for this run
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: 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})." |