diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ee4b39f5..c8c4e5639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,18 @@ jobs: timeout_minutes: 5 command: bun install --frozen-lockfile + # Pinned, because vitest runs under Node and this job had been taking + # whatever the runner image happened to ship. That is how the suite came + # to pass here and fail on contributors' machines: from Node 24 on, Node + # supplies its OWN `localStorage` global, which only works with + # `--localstorage-file` and otherwise shadows jsdom's with `undefined` — + # fifteen failures in `project-list.test.tsx`, green in CI. The polyfill + # in `__tests__/setup.ts` is what actually fixes it; this pin is what + # keeps CI from silently drifting onto a different runtime again. + - uses: actions/setup-node@v7 + with: + node-version: "22" + - name: Test (${{ matrix.env-config.name }}) uses: nick-fields/retry@v4 env: ${{ matrix.env-config.env }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 293de413e..1ea7337ce 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Resolve version and dist-tag @@ -184,6 +184,34 @@ jobs: fi echo "failproofai@$PUBLISH_VERSION is not on the registry yet." + # A stable release announces itself in Discord (the `announce` job at the + # bottom of this file) from the notes on the GitHub Release, falling back + # to CHANGELOG.md. With neither, it posts an announcement with no notes in + # it, to the channel where the most people are looking. + # + # Checked HERE because preflight is the one point in this pipeline where + # failing is nearly free: nothing is built, nothing is attached to the + # release, and nothing is on npm. The remedy is to write the release + # notes, or add the changelog heading, and re-run. + # + # Prereleases are exempt on purpose — they do not announce, and a beta cut + # to test a branch should not be blocked on release notes. + # + # `--check` rather than a grep: both formats are parsed by the code that + # also renders them, so a check here and the message that ships cannot + # disagree about what counts as notes. + - name: Verify this stable release has notes to announce + if: steps.version.outputs.is_prerelease == 'false' + env: + PUBLISH_VERSION: ${{ steps.version.outputs.publish_version }} + RELEASE_BODY: ${{ github.event.release.body }} + run: | + printf '%s' "$RELEASE_BODY" > "${RUNNER_TEMP}/release-body.md" + node scripts/release-announcement.mjs \ + --version "$PUBLISH_VERSION" \ + --notes-file "${RUNNER_TEMP}/release-body.md" \ + --check + # Who may cut a STABLE release. Prereleases are deliberately open: a beta # or a `next` build is how anyone with write access ships a branch for # testing, and npm's `beta`/`next` tags are opt-in. A stable release is @@ -307,7 +335,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # The tarball has to be packed at the version being published, not at # whatever the ref happens to carry — a release from a tag bumps the @@ -482,7 +510,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Set publish version in package.json @@ -762,6 +790,12 @@ jobs: platform: darwin-arm64 runs-on: ${{ matrix.os }} steps: + # Deliberately the OLDEST Node this package claims to support + # (`engines.node: >=20.9.0`), while the build and publish jobs above run + # on 22. This job is the one that stands in for a user, and the users most + # likely to hit a runtime problem are the ones on the floor of that range + # — testing the install on the same Node that built it would prove + # nothing about them. Move this only when `engines.node` moves. - uses: actions/setup-node@v7 with: node-version: "20" @@ -844,3 +878,182 @@ jobs: env: PLATFORM: ${{ matrix.platform }} run: echo "::notice::$PLATFORM installs from the registry and carries a matching daemon." + + # Tells people the release exists, in the one place they are: the #releases + # channel in Discord, pinging the "Notify: Releases" role. + # + # STABLE ONLY, and both halves of that are required rather than either: + # * a prerelease VERSION is a beta, and `failproofai@beta` is opt-in — the + # people tracking it do not need a role ping per build; and + # * a stable version published at a dist-tag other than `latest` is NOT what + # a bare `npm install failproofai` resolves to, so the announcement's + # install line would be wrong on the one line anybody copies. + # + # No `always()` in the `if`: every job in `needs` must have SUCCEEDED. That is + # the point of announcing last — `verify-install` is what proves the release + # is actually installable on all four platforms, and a channel told to install + # something that 404s is worse than a channel told nothing. + # + # Failing this job does NOT unpublish anything and does not mean the release + # is broken. It is the last job in the pipeline and nothing depends on it; a + # red mark here means the announcement did not go out, and re-running the job + # is the whole remedy. + announce: + name: announce (discord) + needs: [preflight, publish, verify-install] + if: >- + needs.preflight.outputs.dry_run != 'true' && + needs.preflight.outputs.is_prerelease == 'false' && + needs.preflight.outputs.dist_tag == 'latest' + runs-on: ubuntu-latest + # Least privilege, declared rather than inherited. This job reads the tree + # and POSTs to a webhook — it writes nothing here — while holding a + # credential that can post to a public channel. Without a block it takes the + # repository or organization default, which may carry write scopes it has no + # use for. + permissions: + contents: read + steps: + # The default ref is right for both entry points: a `release: published` + # checks out the TAG, whose tree carries the `## ` section this + # reads, and a dispatch from main checks out main, which carries the + # version being published for the same reason preflight took it from + # there. The version-bump commit that lands on main earlier in this run + # does not touch CHANGELOG.md. + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: "22" + + # The notes the maintainer wrote on the Releases page are what this + # release SAYS, so they are what gets announced; CHANGELOG.md is the + # fallback for an empty body and for a `workflow_dispatch`, which has no + # release event at all. See the module header in the script. + # + # Through the environment and onto disk, never onto a command line: the + # body is arbitrary markdown typed into a web form, and interpolating + # `${{ github.event.release.body }}` into a `run:` block would let a + # backtick or a `$(…)` in somebody's release notes execute in the release + # pipeline. `printf %s` also keeps a leading `-` from being read as a flag. + - name: Build the announcement + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + TAG: ${{ needs.preflight.outputs.tag }} + RELEASE_BODY: ${{ github.event.release.body }} + # A role id is not a secret — it is visible to every member of the + # server — so it lives as a repository VARIABLE, where a wrong value + # can be read back off the config page instead of being masked out of + # every log that would show it. Read from `secrets` too, because + # "which one did I set it in" is otherwise a silent no-mention. + ROLE_ID: ${{ vars.DISCORD_RELEASE_ROLE_ID || secrets.DISCORD_RELEASE_ROLE_ID }} + run: | + printf '%s' "$RELEASE_BODY" > "${RUNNER_TEMP}/release-body.md" + + ARGS=( + --version "$PUBLISH_VERSION" + --repo "$GITHUB_REPOSITORY" + --notes-file "${RUNNER_TEMP}/release-body.md" + --release-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" + --out "${RUNNER_TEMP}/discord.json" + ) + if [ -n "$ROLE_ID" ]; then + ARGS+=(--role-id "$ROLE_ID") + else + echo "::notice::No DISCORD_RELEASE_ROLE_ID set — announcing without the release-role mention." + fi + node scripts/release-announcement.mjs "${ARGS[@]}" + + # The webhook is bound to ONE channel at creation time, so which channel + # this lands in is a property of the secret, not of this file: create the + # webhook in #releases (Channel -> Integrations -> Webhooks) and store its + # URL as DISCORD_RELEASE_WEBHOOK. + # + # Silently skipping an unset secret keeps a fork's release from going red + # over a channel it does not have. An actual POST failure does not skip — + # a webhook that was deleted, revoked or rate-limited is a real thing to + # fix, and the only way anyone learns it happened is this job. + - name: Post to the releases channel + env: + DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + run: | + if [ -z "$DISCORD_RELEASE_WEBHOOK" ]; then + echo "::notice::No DISCORD_RELEASE_WEBHOOK secret set — release announcement not posted." + exit 0 + fi + + PAYLOAD="${RUNNER_TEMP}/discord.json" + BODY="${RUNNER_TEMP}/discord-response.txt" + ERRLOG="${RUNNER_TEMP}/discord-curl.txt" + + # A Discord webhook has NO idempotency key: every accepted POST + # creates another message, role ping and all. So a retry is only safe + # when the previous attempt is known not to have arrived, and the + # curl exit codes below are the ones that say so — a name that never + # resolved, a connection never made, a TLS handshake that failed + # before any body went out. + # + # 5 could not resolve proxy 6 could not resolve host + # 7 failed to connect 35 TLS connect error + # 60 TLS certificate problem + # + # Everything else is AMBIGUOUS — 28 (timeout), 52 (empty reply), 55 + # and 56 (send/recv errors) can all happen after Discord accepted the + # message and only the response was lost — and is treated as final + # rather than retried, because a duplicate announcement is worse than + # a missing one that a human can re-run deliberately. + NEVER_SENT=" 5 6 7 35 60 " + + for attempt in 1 2 3; do + rc=0 + code="$(curl -sS --connect-timeout 10 --max-time 30 \ + -o "$BODY" -w '%{http_code}' \ + -X POST -H 'Content-Type: application/json' \ + --data-binary @"$PAYLOAD" "$DISCORD_RELEASE_WEBHOOK" 2>>"$ERRLOG")" || rc=$? + + if [ "$rc" -ne 0 ]; then + case "$NEVER_SENT" in + *" $rc "*) + echo "Discord webhook unreachable on attempt $attempt (curl exit $rc) — nothing was sent" >&2 + ;; + *) + echo "::error::The package published successfully. The Discord announcement failed in a way that does not say whether it arrived (curl exit $rc), so it is NOT retried — a webhook has no idempotency key, and replaying a POST that Discord may already have accepted would announce the release twice with the role ping. Look in the releases channel: if nothing is there, re-run this job." + exit 1 + ;; + esac + else + case "$code" in + 200|204) + echo "announced the release in Discord" + exit 0 + ;; + 429) + # Discord answered, so no message was created. Safe to repeat. + echo "Discord webhook rate-limited (429) on attempt $attempt" >&2 + ;; + 5*) + # Same: an error response means nothing was created. + echo "Discord webhook returned HTTP $code on attempt $attempt" >&2 + ;; + 4*) + # A rejected payload is deterministic — two more identical + # POSTs only delay the same error by ten seconds. + echo "Discord rejected the payload with HTTP $code:" >&2 + head -c 500 "$BODY" >&2 || true + echo >&2 + break + ;; + *) + echo "Discord webhook returned an unexpected HTTP $code on attempt $attempt" >&2 + ;; + esac + fi + + if [ "$attempt" -lt 3 ]; then sleep 5; fi + done + + echo "::error::The package published successfully — only the Discord announcement failed, and every attempt was answered in a way that says it was never delivered. Nothing about the release needs re-doing; check DISCORD_RELEASE_WEBHOOK and re-run this job." + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b7955a2..b472798eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,14 @@ ### Features -- Give the canary box three images instead of one, and bake the commit into each. `failproofai-canary`, `failproofai-translate` and `failproofai-docs-audit` replace the single shared toolchain image, and each carries the checkout, its dependencies and its build products — the canary also carries a compiled `failproofaid`, which it used to cross-compile in a sibling rust container on every run. What happened at 02:00 and 11:00 in front of nobody — clone, fetch, checkout, `bun install`, two `bun build`s, a `cargo build` — happens in CI now, once per commit, where a failure is a red build rather than a night with no report. Installing the twelve vendor CLIs @latest deliberately stays at run time: that is the measurement, not setup. **Only the canary carries a docker client**, and only its cron line mounts the socket — the other two spawn nothing, and an image without the client cannot be talked into reaching the host daemon. That split is the reason for three images rather than one, and it is asserted rather than described. (#PR) - - Give the canary box three images instead of one, and bake the commit into each. `failproofai-canary`, `failproofai-translate` and `failproofai-docs-audit` replace the single shared toolchain image, and each carries the checkout, its dependencies and its build products — the canary also carries a compiled `failproofaid`, which it used to cross-compile in a sibling rust container on every run. What happened at 02:00 and 11:00 in front of nobody — clone, fetch, checkout, `bun install`, two `bun build`s, a `cargo build` — happens in CI now, once per commit, where a failure is a red build rather than a night with no report. Installing the twelve vendor CLIs @latest deliberately stays at run time: that is the measurement, not setup. **Only the canary carries a docker client**, and only its cron line mounts the socket — the other two spawn nothing, and an image without the client cannot be talked into reaching the host daemon. That split is the reason for three images rather than one, and it is asserted rather than described. (#705) - Publish those images on **every** push to main, with no path filter — the exact inverse of the old rule, for the same reason it existed. Job scripts used to reach the box through a run-time clone, so only the baked layer needed rebuilding; with the tree baked in, any commit changes what the images should contain and a path filter would leave the box running last week's code with nothing saying so. The staleness that remains is answered rather than prevented: `FP_SHA` and `BUILT_AT` are baked in, the entrypoint turns them into an age, and a stale image says so at the top of its run **and in every Slack report it produces**. An unreadable build date counts as stale, because a job that cannot say how old its own code is should not be claiming freshness. The matrix is `fail-fast: false`, so one broken Dockerfile leaves the other two published rather than freezing all three. (#705) - Stop writing credentials into the checkout, which the images now bake. `ci-entrypoint.sh` decoded the vendor OAuth tarballs into `$REPO/tokens/` and assembled the gateway env-file at `$REPO/canary.env`; both move to a run-scoped directory. That was harmless while no image had a repo-root build context and stopped being harmless the moment one did — a cleanup trap that does not run is the normal case (a killed run, a `docker kill`), and `install.sh --build-local` builds from the operator's own working tree, which on the box is exactly where those files land. Three layers now hold the line: the paths move, a repo-root `.dockerignore` names them anyway (including the beta-leg variants `tokens-beta/` and `canary-beta.env`, which `.gitignore` never covered), and each Dockerfile **refuses to build** if one arrives — so a rename on the writing side fails a build instead of publishing a credential to a public registry. (#705) +- Announce every stable release in Discord, from the notes on the GitHub Release. `publish.yml` gains an `announce` job that runs LAST — after the registry check and all four `verify-install` legs, because a channel told to install something that 404s is worse than a channel told nothing — and posts one embed to the webhook in `DISCORD_RELEASE_WEBHOOK`, pinging the role in `DISCORD_RELEASE_ROLE_ID`. **The GitHub Release body is the source of record and `CHANGELOG.md` the fallback**: stable releases are cut from the Releases page, and the notes written there are what the maintainer decided this release says — announcing from the changelog instead would publish a DIFFERENT summary than the one on the release page, in the channel where more people read it. The fallback covers an empty body and a `workflow_dispatch`, which has no release event at all; read from the changelog, a stable version also collects its whole `-beta.*` line, since somebody moving 1.0.0 → 1.0.1 on `latest` receives all of it and the stable section deliberately does not restate it. Entries collapse to their first sentence — the headline every entry in this file already opens with — and both `(#123)` and GitHub's `by @someone in ` become the same `#123` link. Three things are load-bearing and none are obvious: the mention goes in `content` because **Discord does not resolve mentions inside an embed** and would render `<@&id>` as raw text pinging nobody; `allowed_mentions: {parse: [], roles: [id]}` is what stops an `@everyone` in somebody's release notes reaching the whole server; and the description is fitted by dropping WHOLE groups rather than truncating, because the first version cut the trailing `[Full changelog]` link off a 1.0.0-sized release and ended on `Stop sending anything about a…` — a notification showing a third of a release and pointing nowhere. Stable only, and both halves of that are required: a prerelease version is a beta nobody asked to be pinged about, and a stable version at a dist-tag other than `latest` would carry an install line resolving to something else. Preflight refuses a stable release with notes in neither source, which is the one point in the pipeline where failing costs nothing. Nothing depends on the `announce` job, so a dead webhook can never hold back a published package. (#721) + ### Fixes - Let the box pick the translation model per tier, and stop a re-install double-scheduling the box. `getModelForTier` now reads `TRANSLATE_MODEL_TIER1` / `TRANSLATE_MODEL_TIER23`, so the seven languages most readers actually arrive in can keep a strong model while the long tail runs on something cheap — the CLI's `--model` flag flattens every tier to one model, which is the opposite of what the tier split exists for. Any id the gateway serves over the Anthropic `/v1/messages` shape works, since that is the API the translator speaks (verified: `deepseek-v4-pro` and `deepseek-v4-flash` both answer there). Separately, `install.sh` now strips the pre-marker cron form as well as its own marker: a box set up before the marker existed carries a long-form inline `docker run … -e CANARY_JOB=` line, and matching only the marker left it in place — six entries, every job scheduled twice, one on the old image and one on the new. The per-job flock keeps that from doing damage and turns it into something worse to diagnose: which image runs becomes a coin toss. Found on the real box, whose crontab is exactly that shape. (#705) @@ -26,8 +26,6 @@ - Give the nightly translation a voice when it fails, and a pulse when it does not run. It posted nothing by design — the reasoning being that its output is the pull request — which held for both success shapes and failed for the third: a run that dies also leaves no PR, so failing and idling produced the identical signal, none. Between 2026-08-11 and 2026-08-17 it opened nothing while 28 pages sat missing from 14 locales, and what noticed was a finding in the weekly docs audit rather than the job itself. Failure now posts to the same Slack webhook the other two jobs use, naming the step and carrying the log tail; success stays quiet, because a nightly "all good" is noise. Every exit also writes `last-run.json` into the work dir, and the weekly docs audit reports its AGE — the one failure no error handler can catch is the job never starting, and only a file's age can see that from outside. (#705) -### Fixes - - **Stop the dashboard server's telemetry from stranding its own events, and stop it printing `Error while flushing PostHog` while doing it.** Four options on the `posthog-node` client each disabled a different part of the library's delivery machinery, and together they turned a slow network into lost events plus a stack trace in the user's terminal — the one `failproofai audit` starts, where `launch()`'s log filter only strips the Server Action skew block. The injected `resilientFetch` was the root of it: it retried five times over ~40s and then returned a synthetic `200` so the library would never log a network error, but posthog-node does not merely hand its abort signal to an injected fetch, it **races that fetch against its own `requestTimeout`** (`Promise.race([fetchPromise, deadline])`) precisely because an injected fetch may ignore the signal — which ours did, by stripping it. A ~40s budget racing a 5s deadline can never return in time, so the synthetic `200` was unreachable code, the `console.error` it existed to prevent fired anyway at 5s, and the retries ran on detached from a client that had already given up. Worse, that `200` was the wrong answer even when it did land: posthog-node deliberately does NOT dequeue a batch that failed with a network error, so reporting success is what would have made it discard events that never arrived. The wrapper is gone; plain global fetch is what the library expects. `fetchRetryCount` was `0`, leaving that wrapper as the only thing retrying, at the wrong layer — the library retries inside a single flush, knows which errors are retryable, and keeps its queue coherent while doing it. `requestTimeout` was `5000`, half the library's own default, so every attempt had half the room. And `flushInterval` was `0`, which is falsy and therefore disables the flush timer outright — that is the one that actually stranded events, because the batch posthog-node retains after a network error then had nothing scheduled to resend it and sat in an in-memory queue (`PostHogMemoryStorage`, so nothing survives the process) until some unrelated later event happened to trigger a flush. `flushAt: 1` is unchanged and deliberate: volume is a handful of events per process, batching buys nothing, and sending immediately is the best defense a memory-only queue has against the process dying. Measured against a server that answers correctly but takes 6s — a slow network, not an outage — the old options delivered the event **four times** and logged two flush errors, because the wrapper re-POSTed the same batch on each of its own retries while the library still held its retained copy; the new ones deliver it **once**, with nothing logged. The exit drain is now idempotent, since `beforeExit` re-fires every time a handler schedules async work and an unguarded one started a fresh 30s `shutdown()` on each pass. **No event, trigger or property changed** — all 73 call sites across the three dispatchers fire exactly as before. (#701) - Cover telemetry delivery against the real `posthog-node` and a real socket, in `__tests__/lib/telemetry-delivery.test.ts`. The existing suite mocks the library wholesale, and that mock is what let the above live in the tree: the constructor was called with the right *shape*, so it passed while events were being stranded. The new tests assert on bytes that arrived over a socket — including gunzipping the batch body, without which a green test means nothing, since posthog-node gzips it and a raw read silently parses as "no events delivered". They pin that a captured event reaches `/batch/` with its properties intact, that a transient 500 is retried and still delivered, that a successful flush logs no error, and — for the hook dispatcher carrying the other 37 call sites — that `trackHookEvent` reaches `/capture/` and that `flushHookTelemetry` lands events the caller never awaited. Two facts they nail down rather than fix: posthog-node **overwrites `$lib`** with its own name on the server path, so `trackEvent`'s `"failproofai"` never lands and `product` is the attribution that actually survives (the raw-fetch hook dispatcher has no SDK to overwrite it, so its `"failproofai-hooks"` does); and the opt-out still sends nothing. (#701) @@ -36,6 +34,8 @@ - Correct a comment in `hook-telemetry.ts` claiming `isTelemetryEnabled()` is memoised. `lib/telemetry-enabled.ts` documents at length that it is resolved fresh on every call, deliberately — an opt-out a long-lived process ignores until restart is not an opt-out — so the comment described the exact optimisation that file rejects. (#701) +- Stop the unit suite passing or failing on the runtime rather than on the code. From Node 24 on, Node ships its OWN `localStorage` / `sessionStorage` globals, and they only work when the process was started with `--localstorage-file` — without it the getter answers `undefined` while printing `ExperimentalWarning: localStorage is not available`. Vitest's jsdom environment makes `window === globalThis`, so that getter sits exactly where jsdom's Storage should be and wins, taking out all fifteen `project-list.test.tsx` tests with `Cannot read properties of undefined (reading 'clear')` on every contributor machine running a current Node. CI stayed green throughout, because its `test` job pinned no Node at all and took whatever the runner image happened to ship — so the divergence hid in the one direction nobody checks: local red, CI green, and a suite that is evidence of the runtime rather than of the code. `__tests__/setup.ts` installs an in-memory Storage ONLY where the runtime has not supplied a working one, so a real jsdom Storage is left alone, and the `test` job now pins Node 22 so CI cannot silently drift onto a different runtime again. `publish.yml`'s build and publish jobs move to 22 with it; `verify-install` deliberately stays on 20, the floor of `engines.node`, because it is the job that stands in for a user and the users most likely to hit a runtime problem are the ones sitting on that floor. (#721) + ### Dependencies - Bump `h2` 0.4.15 → 0.4.16 in `Cargo.lock`, clearing RUSTSEC-2026-0258, which turned the Supply Chain gate red on `main` and therefore on every branch cut from it — same shape as the `brace-expansion` and `next`/`sharp` incidents before it: the advisory published after main's last green scan, so nothing in this repo changed to cause it. `h2` is transitive through `hyper`, so the fix is a lockfile edit and nothing else. It is applied surgically rather than by `cargo update -p h2 --precise`, which additionally re-resolved six unrelated `windows-sys` edges *downward* (0.61.2 → 0.52.0/0.60.2) — churn that is invisible in CI, since the Rust jobs run on Linux and never compile those crates, and would have ridden into a release lockfile unreviewed. `cargo metadata --locked` accepts the result, so the resolver agrees the lock is complete and will not re-resolve behind it. (#717) @@ -50,6 +50,8 @@ - Drop the Status link from the docs sidebar. It was a `navigation.global.anchors` entry, which Mintlify pins above the page tree on every page in every tab — permanent real estate for a link that answers a question almost no reader of a docs page is asking. Support stays, since that one is reached from anywhere in the docs by someone who is already stuck. (#718) +- Drop a duplicated entry and a repeated `### Fixes` heading from this release's own section. The canary-images entry was committed twice — once carrying an unfilled `(#PR)` placeholder and once as `(#705)` — and the section then opened a second `### Fixes` block a few entries after the first. Both were invisible while the changelog was only ever read on GitHub; the release announcement renders straight from these sections, so a duplicated headline and a heading appearing twice were about to show up in a public channel. (#721) + ## 1.0.1-beta.0 — 2026-08-14 ### Docs diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index a772a7534..d8de25b38 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -497,6 +497,118 @@ describe("publish.yml", () => { }); }); +/** + * The announcement runs once per stable release and nothing else exercises it, + * so every guard here is for a change that would look harmless in review and + * only show up in a public channel — or not show up at all. + */ +describe("publish.yml / the Discord release announcement", () => { + const wf = workflow("publish.yml"); + const job = wf.jobs.announce; + + it("announces only a STABLE release, at the dist-tag a bare install resolves", () => { + // Both halves are required. A prerelease version is a beta nobody asked to + // be pinged about; a stable version published at `next` is not what + // `npm install failproofai` returns, so the announcement's install line + // would be wrong on the one line people copy. + expect(job.if).toContain("needs.preflight.outputs.is_prerelease == 'false'"); + expect(job.if).toContain("needs.preflight.outputs.dist_tag == 'latest'"); + expect(job.if).toContain("needs.preflight.outputs.dry_run != 'true'"); + }); + + it("announces only after the release is published AND verified installable", () => { + expect(job.needs).toEqual(expect.arrayContaining(["publish", "verify-install"])); + // No `always()`: a job in `needs` that failed must stop the announcement, + // or a channel gets told to install something that 404s. + expect(job.if).not.toContain("always()"); + }); + + it("cannot hold back the release it announces", () => { + // Nothing may depend on `announce`. A red mark there means the message did + // not go out; it must never mean a published package is blocked. + for (const [name, other] of Object.entries>(wf.jobs)) { + if (name === "announce") continue; + expect([other.needs ?? []].flat()).not.toContain("announce"); + } + }); + + it("prefers the GitHub Release body over the changelog", () => { + const build = job.steps.find((s: Record) => s.name === "Build the announcement"); + expect(build.env.RELEASE_BODY).toContain("github.event.release.body"); + expect(build.run).toContain("--notes-file"); + }); + + it("never puts the release body on a command line", () => { + // It is arbitrary markdown typed into a web form. Interpolating it into a + // `run:` block lets a backtick in somebody's release notes execute inside + // the release pipeline. + const build = job.steps.find((s: Record) => s.name === "Build the announcement"); + expect(build.run).not.toContain("github.event.release.body"); + expect(build.run).toContain("printf '%s' \"$RELEASE_BODY\""); + }); + + it("skips silently without a webhook and fails loudly when a post does not land", () => { + const post = job.steps.find((s: Record) => s.name === "Post to the releases channel"); + expect(post.env.DISCORD_RELEASE_WEBHOOK).toContain("secrets.DISCORD_RELEASE_WEBHOOK"); + // A fork with no webhook must not turn a release red... + expect(post.run).toContain('if [ -z "$DISCORD_RELEASE_WEBHOOK" ]'); + expect(post.run).toContain("::notice::"); + // ...but a deleted or revoked webhook is a real failure, and this job is + // the only place anyone would ever learn it happened. + expect(post.run).toContain("::error::"); + expect(post.run.trimEnd().endsWith("exit 1")).toBe(true); + }); + + it("never retries a post that may already have arrived", () => { + // A Discord webhook has no idempotency key, so every accepted POST creates + // another message — retrying a transport failure that happened AFTER the + // body went out announces the release twice, role ping and all. Only the + // curl exits that mean "never left this runner" may be repeated. + const post = job.steps.find((s: Record) => s.name === "Post to the releases channel"); + expect(post.run).toContain("NEVER_SENT="); + const retryable = /NEVER_SENT="([^"]*)"/.exec(post.run)![1].trim().split(/\s+/); + + // Asserted as an exact ALLOWLIST, not as the absence of the four ambiguous + // codes below. Naming what may be retried is the same reason the shell uses + // an allowlist: a denylist only rejects the failure modes somebody thought + // of, and curl has plenty more that can land after the body went out (18 + // partial transfer, 56 recv error, …). Widen this deliberately or not at + // all. 5 proxy, 6 host, 7 connect, 35 TLS connect, 60 TLS certificate. + expect(retryable).toEqual(["5", "6", "7", "35", "60"]); + // Named individually too, so a failure says WHICH kind of ambiguity got in. + for (const ambiguous of ["28", "52", "55", "56"]) { + expect(retryable).not.toContain(ambiguous); + } + + // A 4xx is deterministic, and the `break` has to be inside that branch — + // anywhere else it would stop the loop on a retryable outcome instead. + expect(post.run).toMatch(/4\*\)[\s\S]*?break/); + }); + + it("holds the webhook credential under least privilege", () => { + // Declared, not inherited: without a block the job takes the repository or + // organization default, which may carry write scopes it has no use for. + expect(job.permissions).toEqual({ contents: "read" }); + }); + + it("reads the release role from a repository variable or a secret", () => { + const build = job.steps.find((s: Record) => s.name === "Build the announcement"); + expect(build.env.ROLE_ID).toContain("vars.DISCORD_RELEASE_ROLE_ID"); + expect(build.env.ROLE_ID).toContain("secrets.DISCORD_RELEASE_ROLE_ID"); + }); + + it("refuses a stable release with nothing to announce, before anything is built", () => { + // In PREFLIGHT, the one point in this pipeline where failing costs nothing: + // no cross-compile, no release assets, no npm publish. + const step = wf.jobs.preflight.steps.find( + (s: Record) => s.name === "Verify this stable release has notes to announce", + ); + expect(step).toBeDefined(); + expect(step.if).toContain("is_prerelease == 'false'"); + expect(step.run).toContain("--check"); + }); +}); + describe("pipeline / CLI agreement", () => { const DAEMON_SERVICE = resolve(ROOT, "src/hooks/daemon-service.ts"); diff --git a/__tests__/scripts/release-announcement.test.ts b/__tests__/scripts/release-announcement.test.ts new file mode 100644 index 000000000..233ddcc60 --- /dev/null +++ b/__tests__/scripts/release-announcement.test.ts @@ -0,0 +1,450 @@ +// @vitest-environment node +/** + * The release announcement is posted exactly once per stable release, from a + * workflow job that only runs on a real release — so nothing routine exercises + * this code, and the first time anyone sees its output is in a public channel + * with a role ping attached. These tests are the rehearsal. + * + * The cases that matter most are the ones that FAIL QUIETLY: a description + * truncated past the changelog link (a message showing a third of a release and + * pointing nowhere), a mention placed inside the embed (renders as raw + * `<@&123>` and pings nobody), and `allowed_mentions` wide enough to let an + * `@everyone` in someone's release notes reach the whole server. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + parseChangelog, + parseSectionBody, + collectRelease, + parseReleaseBody, + chooseNotes, + firstSentence, + truncate, + summarizeEntry, + fitDescription, + buildDiscordPayload, + BRAND_COLOR, +} from "../../scripts/release-announcement.mjs"; + +const REPO = "failproof-ai/failproofai"; + +const CHANGELOG = `# Changelog + +## 2.1.0 — 2026-09-01 + +The prose preamble. It runs to two sentences. + +A second paragraph nobody needs in a notification. + +### Features + +- Add the first thing. It has a long tail of explanation that goes on and on and should not survive. (#101) + +- Add the second thing, whose headline mentions \`some.file.ts\` and version 1.2.3 mid-sentence. Then more. (#102) + +### Fixes + +- Fix a thing. (#103) + +## 2.1.0-beta.1 — 2026-08-20 + +### Features + +- A beta feature. (#104) + +### Fixes + +- A beta fix. (#105) + +### Fixes + +- A second block of fixes under a repeated heading. (#106) + +### Dependencies + +- Bump something from 1.0.0 to 1.0.1. (#107) + +## 2.0.0 — 2026-07-01 + +### Features + +- Something from an older release nobody is announcing. (#001) +`; + +describe("parseChangelog", () => { + it("splits on version headings and keeps file order", () => { + const sections = parseChangelog(CHANGELOG); + expect(sections.map((s) => s.version)).toEqual(["2.1.0", "2.1.0-beta.1", "2.0.0"]); + expect(sections[0].date).toBe("2026-09-01"); + }); + + it("accepts an em dash, an en dash, a hyphen, or no date at all", () => { + const sections = parseChangelog("## 1.0.0 — a\n## 1.0.1 – b\n## 1.0.2 - c\n## 1.0.3\n"); + expect(sections.map((s) => s.version)).toEqual(["1.0.0", "1.0.1", "1.0.2", "1.0.3"]); + expect(sections[3].date).toBe(""); + }); + + it("keeps two sections carrying the same version rather than dropping one", () => { + // The real file has two `## 1.0.0-beta.13` and two `## 1.0.0-beta.15` + // headings; collapsing by version would silently drop half their entries. + const sections = parseChangelog("## 1.0.0 — a\n\n### Fixes\n\n- one\n\n## 1.0.0 — b\n\n### Fixes\n\n- two\n"); + expect(sections).toHaveLength(2); + }); +}); + +describe("parseSectionBody", () => { + it("separates the prose preamble from the grouped entries", () => { + const section = parseChangelog(CHANGELOG)[0]; + const { lead, groups } = parseSectionBody(section.body); + expect(lead).toMatch(/^The prose preamble\./); + expect(groups.map((g) => g.name)).toEqual(["Features", "Fixes"]); + expect(groups[0].entries).toHaveLength(2); + }); + + it("joins an entry that wraps across lines", () => { + const { groups } = parseSectionBody("### Fixes\n\n- A headline\n that wrapped onto a second line. (#9)\n"); + expect(groups[0].entries[0]).toBe("A headline that wrapped onto a second line. (#9)"); + }); +}); + +describe("collectRelease", () => { + it("gathers the stable section and its whole beta line", () => { + const notes = collectRelease(CHANGELOG, "2.1.0"); + expect(notes!.sections).toEqual(["2.1.0", "2.1.0-beta.1"]); + expect(notes!.total).toBe(7); + }); + + it("merges groups that share a heading instead of listing them twice", () => { + const notes = collectRelease(CHANGELOG, "2.1.0"); + const fixes = notes!.groups.filter((g) => g.name === "Fixes"); + expect(fixes).toHaveLength(1); + // The stable section's one fix plus both repeated beta blocks. + expect(fixes[0].entries).toHaveLength(3); + }); + + it("does not reach into an unrelated version's section", () => { + const notes = collectRelease(CHANGELOG, "2.1.0"); + const headlines = notes!.groups.flatMap((g) => g.entries.map((e: { headline: string }) => e.headline)); + expect(headlines.join(" ")).not.toContain("older release"); + }); + + it("returns null when the version has no section", () => { + expect(collectRelease(CHANGELOG, "9.9.9")).toBeNull(); + }); + + it("does not treat 2.1.0 as a prefix of 2.1.05", () => { + // Only an exact match or a `-` suffix counts; otherwise a 1.0.1 release + // would sweep in 1.0.10's entries. + const notes = collectRelease("## 1.0.1 — a\n\n### Fixes\n\n- one\n\n## 1.0.10 — b\n\n### Fixes\n\n- two\n", "1.0.1"); + expect(notes!.total).toBe(1); + }); +}); + +describe("firstSentence", () => { + it("stops at the first real sentence boundary", () => { + expect(firstSentence("Add the first thing. It has a long tail of explanation.")).toBe("Add the first thing."); + }); + + it("does not split inside an inline code span", () => { + expect(firstSentence("It reads `~/.failproofai/run/socket` at startup. Then it binds.")).toBe( + "It reads `~/.failproofai/run/socket` at startup.", + ); + }); + + it("does not split on a version number", () => { + expect(firstSentence("Bump h2 from 0.4.15 to 0.4.16 in the lockfile. It clears an advisory.")).toBe( + "Bump h2 from 0.4.15 to 0.4.16 in the lockfile.", + ); + }); + + it("keeps a whole sentence that is wrapped in bold", () => { + const text = "**Stop the digest shipping assigned secrets verbatim.** Four options were wrong."; + expect(firstSentence(text)).toBe("**Stop the digest shipping assigned secrets verbatim.**"); + }); + + it("does not split on an abbreviation", () => { + expect(firstSentence("Every path bearing tool, e.g. Read and Write, is canonicalized now. Then mapped.")).toBe( + "Every path bearing tool, e.g. Read and Write, is canonicalized now.", + ); + }); + + it("returns the whole text when there is no boundary", () => { + expect(firstSentence("A headline with no terminator")).toBe("A headline with no terminator"); + }); +}); + +describe("truncate", () => { + it("leaves short text alone", () => { + expect(truncate("short", 20)).toBe("short"); + }); + + it("cuts at a word boundary and marks the cut", () => { + const out = truncate("alpha beta gamma delta epsilon zeta", 20); + expect(out.endsWith("…")).toBe(true); + expect(out.length).toBeLessThanOrEqual(20); + expect(out).not.toMatch(/\s…$/); + }); +}); + +describe("summarizeEntry", () => { + it("links a trailing changelog PR reference", () => { + expect(summarizeEntry("Do a thing. (#123)")).toEqual({ headline: "Do a thing.", pr: 123 }); + }); + + it("strips GitHub's attribution and keeps the pull number", () => { + expect( + summarizeEntry("Close four ways enforcement failed silently by @someone in https://github.com/o/r/pull/683"), + ).toEqual({ headline: "Close four ways enforcement failed silently", pr: 683 }); + }); + + it("reads a bare pull URL", () => { + expect(summarizeEntry("Do a thing https://github.com/o/r/pull/42").pr).toBe(42); + }); + + it("yields no link for a non-numeric placeholder rather than a broken one", () => { + // `(#PR)` is a real thing that reached this changelog unfilled. + expect(summarizeEntry("Do a thing. (#PR)")).toEqual({ headline: "Do a thing.", pr: null }); + }); +}); + +describe("parseReleaseBody", () => { + const GENERATED = [ + "The lead paragraph.", + "", + "## What's Changed", + "* Close four ways enforcement failed silently by @a in https://github.com/o/r/pull/683", + "* Give config a Recommended path by @b in https://github.com/o/r/pull/684", + "", + "### Fixes", + "* Make a deny actually enforce by @c in https://github.com/o/r/pull/690", + "", + "**Full Changelog**: https://github.com/o/r/compare/v1.0.0...v1.0.1", + ].join("\n"); + + it("parses GitHub's generated shape", () => { + const notes = parseReleaseBody(GENERATED)!; + expect(notes.lead).toBe("The lead paragraph."); + expect(notes.groups.map((g) => g.name)).toEqual(["Changes", "Fixes"]); + expect(notes.total).toBe(3); + }); + + it("keeps the compare URL and takes it out of the entries", () => { + const notes = parseReleaseBody(GENERATED)!; + expect(notes.compareUrl).toBe("https://github.com/o/r/compare/v1.0.0...v1.0.1"); + const headlines = notes.groups.flatMap((g) => g.entries.map((e) => e.headline)); + expect(headlines.join(" ")).not.toContain("Full Changelog"); + }); + + it("renames GitHub's 'What's Changed' to something that reads in a chat message", () => { + expect(parseReleaseBody(GENERATED)!.groups[0].name).toBe("Changes"); + }); + + it("keeps bullets that have no heading above them", () => { + const notes = parseReleaseBody("- one thing\n- another thing\n")!; + expect(notes.groups).toHaveLength(1); + expect(notes.groups[0].name).toBe("Highlights"); + expect(notes.total).toBe(2); + }); + + it("keeps notes that are prose only", () => { + const notes = parseReleaseBody("Just a paragraph about this release.")!; + expect(notes.lead).toBe("Just a paragraph about this release."); + expect(notes.total).toBe(0); + }); + + it("returns null for an empty or whitespace-only body", () => { + expect(parseReleaseBody("")).toBeNull(); + expect(parseReleaseBody(" \n\n ")).toBeNull(); + expect(parseReleaseBody(undefined)).toBeNull(); + }); +}); + +describe("chooseNotes", () => { + it("prefers the GitHub Release body over the changelog", () => { + const notes = chooseNotes({ + releaseBody: "## What's Changed\n* From the release page by @a in https://github.com/o/r/pull/1", + changelog: CHANGELOG, + version: "2.1.0", + })!; + expect(notes.sections).toEqual(["the GitHub Release body"]); + expect(notes.groups[0].entries[0].headline).toBe("From the release page"); + }); + + it("falls back to the changelog when the body is empty", () => { + const notes = chooseNotes({ releaseBody: "", changelog: CHANGELOG, version: "2.1.0" })!; + expect(notes.sections).toEqual(["2.1.0", "2.1.0-beta.1"]); + }); + + it("returns null when neither source has anything", () => { + expect(chooseNotes({ releaseBody: "", changelog: CHANGELOG, version: "9.9.9" })).toBeNull(); + }); +}); + +describe("fitDescription", () => { + const link = "https://example.com/changelog"; + + it("always ends on the changelog link", () => { + const out = fitDescription({ + lead: "lead", + blocks: [{ text: "x".repeat(3000), entries: 10 }, { text: "y".repeat(3000), entries: 10 }], + deprioritized: 0, + changelogUrl: link, + budget: 4096, + }); + expect(out.length).toBeLessThanOrEqual(4096); + // The link is the LAST line, not merely present somewhere in the middle of + // a truncated body — that was the bug this whole function exists to fix. + expect(out.trimEnd().split("\n").at(-1)).toMatch(/^\[Full changelog\]\(https:\/\/example\.com\/changelog\)/); + }); + + it("drops whole blocks rather than cutting one mid-sentence", () => { + const out = fitDescription({ + lead: "", + blocks: [{ text: "AAA", entries: 1 }, { text: "y".repeat(5000), entries: 4 }], + deprioritized: 0, + changelogUrl: link, + budget: 4096, + }); + expect(out).toContain("AAA"); + expect(out).not.toContain("yyy"); + }); + + it("counts what it left out instead of implying it showed everything", () => { + const out = fitDescription({ + lead: "", + blocks: [{ text: "AAA", entries: 1 }, { text: "y".repeat(5000), entries: 4 }], + deprioritized: 2, + changelogUrl: link, + budget: 4096, + }); + expect(out).toContain("4 more entries"); + expect(out).toContain("2 dependency updates"); + }); + + it("says nothing about omissions when nothing was omitted", () => { + const out = fitDescription({ + lead: "", + blocks: [{ text: "AAA", entries: 1 }], + deprioritized: 0, + changelogUrl: link, + budget: 4096, + }); + expect(out).toBe(`AAA\n\n[Full changelog](${link})`); + }); + + it("cuts an oversized lead rather than losing the link", () => { + const out = fitDescription({ + lead: "L".repeat(9000), + blocks: [], + deprioritized: 0, + changelogUrl: link, + budget: 500, + }); + expect(out.length).toBeLessThanOrEqual(500); + expect(out).toContain(link); + }); +}); + +describe("buildDiscordPayload", () => { + const notes = collectRelease(CHANGELOG, "2.1.0"); + const base = { version: "2.1.0", repo: REPO, notes, timestamp: "2026-09-01T00:00:00.000Z" }; + + it("puts the role mention in content, where Discord will actually resolve it", () => { + const payload = buildDiscordPayload({ ...base, roleId: "555" }); + expect(payload.content).toContain("<@&555>"); + // Mentions inside an embed render as raw text and ping nobody. + expect(JSON.stringify(payload.embeds)).not.toContain("<@&555>"); + }); + + it("allows the release role and nothing else to ping", () => { + const payload = buildDiscordPayload({ ...base, roleId: "555" }); + expect(payload.allowed_mentions).toEqual({ parse: [], roles: ["555"] }); + }); + + it("cannot ping @everyone even when the notes contain it", () => { + const payload = buildDiscordPayload({ + ...base, + notes: parseReleaseBody("- heads up @everyone something changed"), + roleId: "555", + }); + // The text survives; `parse: []` is what stops it resolving. + expect(payload.embeds[0].description).toContain("@everyone"); + expect(payload.allowed_mentions.parse).toEqual([]); + }); + + it("announces without a mention when no role is configured", () => { + const payload = buildDiscordPayload({ ...base, roleId: null }); + expect(payload.content).not.toContain("<@&"); + expect(payload.allowed_mentions.roles).toEqual([]); + }); + + it("carries the version, the release link and the install line", () => { + const payload = buildDiscordPayload({ ...base, releaseUrl: "https://example.com/rel" }); + const [embed] = payload.embeds; + expect(embed.title).toBe("failproofai v2.1.0"); + expect(embed.url).toBe("https://example.com/rel"); + expect(embed.color).toBe(BRAND_COLOR); + expect(embed.fields.find((f: { name: string }) => f.name === "Install")!.value).toContain( + "npm install -g failproofai", + ); + }); + + it("links each entry to its pull request", () => { + const payload = buildDiscordPayload(base); + expect(payload.embeds[0].description).toContain(`[#101](https://github.com/${REPO}/pull/101)`); + }); + + it("leaves dependency bumps out of the highlights but counts them", () => { + const payload = buildDiscordPayload(base); + const { description } = payload.embeds[0]; + expect(description).not.toContain("**Dependencies**"); + expect(description).toContain("1 dependency update"); + }); + + it("shows dependency bumps when they are all the release has", () => { + const onlyDeps = collectRelease("## 3.0.0 — x\n\n### Dependencies\n\n- Bump a thing. (#1)\n", "3.0.0"); + const description = buildDiscordPayload({ ...base, version: "3.0.0", notes: onlyDeps }).embeds[0].description; + expect(description).toContain("**Dependencies**"); + expect(description).toContain("Bump a thing."); + }); + + it("still announces when there are no notes at all", () => { + const payload = buildDiscordPayload({ ...base, notes: null }); + expect(payload.content).toContain("failproofai v2.1.0"); + expect(payload.embeds[0].description).toContain("Full changelog"); + }); + + it("prefers the release body's compare link over a blob link when one exists", () => { + const payload = buildDiscordPayload({ + ...base, + notes: parseReleaseBody("- a thing\n\n**Full Changelog**: https://github.com/o/r/compare/v1...v2"), + }); + expect(payload.embeds[0].description).toContain("https://github.com/o/r/compare/v1...v2"); + }); + + it("stays inside every Discord limit, even for the largest release this repo has cut", () => { + // 1.0.0 aggregates 24 changelog sections and 246 entries — comfortably the + // worst case, and the one that first pushed the description past 4096. + const real = collectRelease(readFileSync(resolve(process.cwd(), "CHANGELOG.md"), "utf8"), "1.0.0"); + const payload = buildDiscordPayload({ version: "1.0.0", repo: REPO, notes: real, roleId: "555" }); + const [embed] = payload.embeds; + + expect(payload.content.length).toBeLessThanOrEqual(2000); + expect(embed.title.length).toBeLessThanOrEqual(256); + expect(embed.description.length).toBeLessThanOrEqual(4096); + for (const field of embed.fields) { + expect(field.name.length).toBeLessThanOrEqual(256); + expect(field.value.length).toBeLessThanOrEqual(1024); + } + const total = + embed.title.length + + embed.description.length + + embed.footer.text.length + + embed.fields.reduce((n: number, f: { name: string; value: string }) => n + f.name.length + f.value.length, 0); + expect(total).toBeLessThanOrEqual(6000); + expect(embed.description).toContain("Full changelog"); + }); +}); diff --git a/__tests__/setup.ts b/__tests__/setup.ts index 3f3327c3c..ac7c958de 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -1,5 +1,59 @@ import "@testing-library/jest-dom"; +/** + * Web Storage, on runtimes that shadow jsdom's. + * + * Node grew its OWN `localStorage`/`sessionStorage` globals, and from Node 24 on + * they are unflagged — but they only work when the process was started with + * `--localstorage-file`, and without it the getter answers `undefined` while + * printing `ExperimentalWarning: localStorage is not available`. Vitest's jsdom + * environment makes `window === globalThis`, so that getter sits exactly where + * jsdom's Storage should be and wins. + * + * The visible effect was fifteen failures in `project-list.test.tsx` — all of + * them `Cannot read properties of undefined (reading 'clear')` — on any + * developer machine running a current Node, while CI stayed green on the + * older Node its runner image happened to ship. A suite that passes or fails on + * the runtime rather than on the code is not a suite anyone can trust, and the + * divergence hid in the one direction nobody checks: local red, CI green. + * + * Defined only when the runtime has not supplied a working one, so a real + * jsdom Storage (Node 20/22, or any run given `--localstorage-file`) is left + * alone. Both keys are `configurable` accessors, which is what makes them + * redefinable at all. + */ +function installWebStorage(name: "localStorage" | "sessionStorage"): void { + let existing: unknown; + try { + existing = (globalThis as Record)[name]; + } catch { + // Node's getter throws rather than answering on some configurations. + existing = undefined; + } + if (existing) return; + + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + key: (i: number) => [...store.keys()][i] ?? null, + getItem: (k: string) => (store.has(String(k)) ? store.get(String(k))! : null), + setItem: (k: string, v: string) => void store.set(String(k), String(v)), + removeItem: (k: string) => void store.delete(String(k)), + clear: () => store.clear(), + }; + + Object.defineProperty(globalThis, name, { + value: storage, + writable: true, + configurable: true, + }); +} + +installWebStorage("localStorage"); +installWebStorage("sessionStorage"); + /** * Unit tests may not reach the public internet. * diff --git a/scripts/release-announcement.mjs b/scripts/release-announcement.mjs new file mode 100644 index 000000000..07a4fa7fe --- /dev/null +++ b/scripts/release-announcement.mjs @@ -0,0 +1,686 @@ +/** + * Build the Discord webhook payload for a STABLE release. + * + * Called by the `announce` job in `.github/workflows/publish.yml`, after the + * registry check and the four `verify-install` legs have proved the release is + * real. + * + * **The GitHub Release body is the source of record; CHANGELOG.md is the + * fallback.** Stable releases are cut from the GitHub Releases page, and the + * notes written there are what the maintainer decided this release says — + * chosen for an audience, ordered on purpose, sometimes rewritten from the + * changelog entirely. Announcing from CHANGELOG.md instead would publish a + * DIFFERENT summary than the one on the release page, in the channel where more + * people read it. So the body wins whenever there is one, and the changelog + * covers the two cases where there is not: an empty release body, and a + * `workflow_dispatch` build, which has no release event at all. + * + * Three other things are load-bearing and easy to get wrong: + * + * **From the changelog, a stable release carries its whole beta line.** Somebody + * on `latest` moving 1.0.0 -> 1.0.1 receives everything that shipped across + * `1.0.1-beta.*` as well as whatever the release commit itself carried, and the + * `## 1.0.1` section deliberately does NOT restate it ("Everything below this + * heading shipped across the `1.0.0-beta.*` line" — the 1.0.0 section says so + * in as many words). Announcing only the stable section would describe a + * release as a handful of entries when it is forty, so `collectRelease` gathers + * `## ` and every `## -*` section under it and merges them. + * + * **Only the first sentence of an entry survives.** This changelog's entries are + * paragraphs — several are over 2000 characters, which is the entire Discord + * message limit on its own. The first sentence is written as a headline in + * every entry in the file, so it is the one summary already there rather than + * one this script invents. GitHub's own generated notes are one PR title per + * bullet, so the same pass leaves them untouched. + * + * **The mention goes in `content`, never in the embed.** Discord does not + * resolve mentions inside embeds — a `<@&id>` there renders as the raw string + * and pings nobody. Paired with `allowed_mentions: {parse: [], roles: [id]}` + * so that the release role is the ONLY thing that can be pinged: release notes + * reach the embed unescaped, and `parse: []` is what stops an `@everyone` in + * them from becoming one. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The brand pink — `HUES.pink` in `src/hooks/tui.ts`, as Discord's integer. */ +export const BRAND_COLOR = 0xff2e88; + +/** + * Entries under these headings are left out of the embed. A notification is + * not the changelog; it links to it. Dependency bumps are the one category + * nobody reads a release announcement for, and they routinely outnumber + * everything else. `collectRelease` still counts them, so the "+N more" line + * stays honest, and a release that is ONLY dependency bumps falls back to + * showing them rather than announcing nothing. + */ +const DEPRIORITIZED_GROUPS = new Set(["dependencies"]); + +/** Highlights shown per heading before the rest collapse into a "+N more". */ +const MAX_ENTRIES_PER_GROUP = 6; + +/** A headline longer than this is cut at a word boundary. */ +const MAX_HEADLINE = 180; + +/** How much of a stable section's prose preamble reaches the embed. */ +const MAX_LEAD = 600; + +// Discord's documented maxima. Exceeding any one of them is a 400 from the +// webhook, i.e. a release that publishes fine and announces nothing. +const LIMIT_CONTENT = 2000; +const LIMIT_DESCRIPTION = 4096; +const LIMIT_FIELD_VALUE = 1024; +const LIMIT_TITLE = 256; +const LIMIT_EMBED_TOTAL = 6000; + +// ── Changelog parsing ─────────────────────────────────────────────────────── + +/** + * Split `CHANGELOG.md` into `## ` sections. + * + * The separator is matched as em dash, en dash or hyphen because the file is + * hand-written and a heading is exactly the kind of line where that slips. The + * date is optional for the same reason: a heading with no date is still a + * release, and refusing to parse it would take the announcement down over + * punctuation. + * + * Sections are returned in FILE order (newest first) and duplicate versions are + * kept as separate sections — the file genuinely carries two `## 1.0.0-beta.13` + * headings and two `## 1.0.0-beta.15` headings, and dropping either would drop + * real entries. `collectRelease` merges them. + */ +export function parseChangelog(text) { + const lines = String(text).split(/\r?\n/); + const sections = []; + let current = null; + + for (const line of lines) { + const heading = /^##\s+(\S+)\s*(?:[—–-]\s*(.*))?$/.exec(line); + if (heading) { + current = { version: heading[1], date: (heading[2] ?? "").trim(), body: [] }; + sections.push(current); + continue; + } + if (current) current.body.push(line); + } + + return sections.map((s) => ({ ...s, body: s.body.join("\n") })); +} + +/** + * Split a section body into `### ` groups plus any prose that precedes + * the first one. + * + * Bullets are joined across continuation lines: an entry runs until the next + * line that starts a new bullet or a new heading. Every entry in this file is + * currently one long line, which is exactly why a wrapped one would otherwise + * be silently truncated at the wrap. + */ +export function parseSectionBody(body) { + const lines = String(body).split(/\r?\n/); + const lead = []; + const groups = []; + let group = null; + let entry = null; + + const flush = () => { + if (entry !== null && group) group.entries.push(entry.join(" ").trim()); + entry = null; + }; + + for (const line of lines) { + const heading = /^###\s+(.+?)\s*$/.exec(line); + if (heading) { + flush(); + group = { name: heading[1], entries: [] }; + groups.push(group); + continue; + } + + const bullet = /^[-*]\s+(.*)$/.exec(line); + if (bullet && group) { + flush(); + entry = [bullet[1]]; + continue; + } + + if (entry !== null) { + // A blank line ends an entry; anything else continues it. + if (line.trim() === "") flush(); + else entry.push(line.trim()); + continue; + } + + if (!group) lead.push(line); + } + flush(); + + return { lead: lead.join("\n").trim(), groups }; +} + +/** + * Gather the stable section for `version` plus every prerelease section under + * it, merging groups of the same name. + * + * Merging by name is not tidiness: `1.0.1-beta.2` carries TWO `### Fixes` + * headings, so keying on the heading text is what keeps the second one's + * entries from replacing the first one's — and what keeps `Fixes` from + * appearing twice in the embed. + * + * Only the stable section's prose preamble is kept. A beta's preamble is + * written for people tracking betas, and stacking four of them would bury the + * entries. + */ +export function collectRelease(changelogText, version) { + const sections = parseChangelog(changelogText); + const mine = sections.filter((s) => s.version === version || s.version.startsWith(`${version}-`)); + if (mine.length === 0) return null; + + const byName = new Map(); + let lead = ""; + let date = ""; + + for (const section of mine) { + const parsed = parseSectionBody(section.body); + if (section.version === version) { + if (!lead) lead = parsed.lead; + if (!date) date = section.date; + } + for (const group of parsed.groups) { + const key = group.name.trim().toLowerCase(); + if (!byName.has(key)) byName.set(key, { name: group.name.trim(), entries: [] }); + byName.get(key).entries.push(...group.entries); + } + } + + const groups = [...byName.values()].map((g) => ({ + name: g.name, + entries: g.entries.map(summarizeEntry), + })); + + return { + version, + date, + lead, + groups, + total: groups.reduce((n, g) => n + g.entries.length, 0), + sections: mine.map((s) => s.version), + }; +} + +/** + * A GitHub Release body -> the same `{lead, groups, total}` shape a changelog + * section produces, so everything downstream is source-agnostic. + * + * Two shapes arrive here and both have to work: + * + * * **GitHub's generated notes** — `## What's Changed`, then one + * `* by @someone in ` per merged PR, then a + * `**Full Changelog**: ` line. The trailing attribution is + * stripped and the pull URL becomes the `#NNN` link, because "by @x in + * https://github.com/…/pull/123" is 60 characters of noise per line in a + * message with a 4096-character budget. + * + * * **Hand-written notes** — arbitrary markdown, usually `###` sections with + * `-` bullets, which is the changelog's own shape. + * + * Notes with no headings at all are still notes: their bullets land in one + * unnamed group rather than being dropped, and prose with no bullets at all + * becomes the lead. The empty case returns null so the caller can fall back. + */ +export function parseReleaseBody(text) { + const body = String(text ?? "").trim(); + if (!body) return null; + + const lead = []; + const groups = []; + let group = null; + let entry = null; + let compareUrl = null; + + const flush = () => { + if (entry !== null) { + const joined = entry.join(" ").trim(); + if (joined) { + if (!group) { + group = { name: "Highlights", entries: [] }; + groups.push(group); + } + group.entries.push(joined); + } + } + entry = null; + }; + + for (const line of body.split(/\r?\n/)) { + const heading = /^#{2,4}\s+(.+?)\s*$/.exec(line); + if (heading) { + flush(); + group = { name: heading[1].replace(/^what's changed$/i, "Changes"), entries: [] }; + groups.push(group); + continue; + } + + // GitHub appends this to every generated body. We render our own link, and + // a compare URL is a better one than a blob URL when it is offered. + const full = /^\*{0,2}Full Changelog\*{0,2}:\s*(\S+)\s*$/i.exec(line.trim()); + if (full) { + flush(); + compareUrl = full[1]; + continue; + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); + if (bullet) { + flush(); + entry = [bullet[1]]; + continue; + } + + if (entry !== null) { + if (line.trim() === "") flush(); + else entry.push(line.trim()); + continue; + } + + if (!group) lead.push(line); + } + flush(); + + const parsed = groups + .map((g) => ({ name: g.name, entries: g.entries.map(summarizeEntry) })) + .filter((g) => g.entries.length > 0); + + const leadText = lead.join("\n").trim(); + if (parsed.length === 0 && !leadText) return null; + + return { + lead: leadText, + groups: parsed, + total: parsed.reduce((n, g) => n + g.entries.length, 0), + compareUrl, + sections: ["the GitHub Release body"], + }; +} + +/** + * Pick the source and return the notes, or null when neither has anything. + * The precedence — release body, then changelog — is the whole point; see the + * module header. + */ +export function chooseNotes({ releaseBody, changelog, version }) { + return ( + parseReleaseBody(releaseBody) ?? + (changelog ? collectRelease(changelog, version) : null) + ); +} + +// ── Entry summarizing ─────────────────────────────────────────────────────── + +/** + * Abbreviations whose trailing period does not end a sentence. Without these, + * "e.g. the daemon" splits after "e.g." and the headline is two words long. + */ +const ABBREVIATIONS = new Set(["e.g", "i.e", "cf", "vs", "etc", "approx", "incl", "no", "fig"]); + +/** + * The first sentence of `text`, with inline code spans protected. + * + * Periods are everywhere in this changelog that are not sentence ends — + * `policy-evaluator.ts`, `1.0.15`, `~/.failproofai/run/` — and almost all of + * them sit inside backticks, so the spans are masked before the search and + * restored after. What is left is a period (or `!`/`?`) that may be followed by + * closing emphasis (`**`, `_`, `` ` ``, `)`, `"`), then whitespace, then the + * start of a new sentence. Entries in this file routinely open with a whole + * bolded sentence — `**Stop the digest shipping secrets verbatim.**` — which is + * why the closing markers have to be consumed before the boundary, not after. + * + * A candidate boundary that would leave less than `MIN_SENTENCE` characters is + * skipped: it is nearly always a false positive on an initial or a stray + * abbreviation, and a six-character headline is worse than a long one. The + * length is measured on the RESTORED text — an entry opening with a long path + * in backticks masks down to three characters, and measuring the masked form + * rejected every real boundary after it. + */ +export function firstSentence(text) { + const MIN_SENTENCE = 12; + const spans = []; + const masked = String(text).replace(/`[^`]*`/g, (m) => { + spans.push(m); + return `${spans.length - 1}`; + }); + const restore = (s) => s.replace(/(\d+)/g, (_, i) => spans[Number(i)]); + + const boundary = /([.!?])([*_`)"'\]]*)(\s+)/g; + let match; + while ((match = boundary.exec(masked)) !== null) { + const end = match.index + match[1].length + match[2].length; + const candidate = restore(masked.slice(0, end)).trim(); + if (candidate.length < MIN_SENTENCE) continue; + + const before = masked.slice(0, match.index); + const word = /([A-Za-z.]+)$/.exec(before)?.[1] ?? ""; + if (ABBREVIATIONS.has(word.toLowerCase().replace(/\.$/, ""))) continue; + + // A lone capital before the period is an initial ("J. Smith"), not an end. + if (/(^|\s)[A-Z]$/.test(before)) continue; + + return candidate; + } + + return restore(masked).trim(); +} + +/** Cut at a word boundary, so a headline never ends mid-token. */ +export function truncate(text, max) { + const s = String(text); + if (s.length <= max) return s; + const cut = s.slice(0, max - 1); + const space = cut.lastIndexOf(" "); + return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:—–-]+$/, "")}…`; +} + +/** + * One entry — from either source — into `{headline, pr}`. + * + * The PR reference is read off the END of the WHOLE entry before the first + * sentence is taken, because that is where it lives in both formats and the + * first sentence never contains it. Three trailing forms are recognised: the + * changelog's `(#123)`, GitHub's `by @someone in `, and a bare pull + * URL. `(#PR)` and other non-numeric placeholders yield no link rather than a + * broken one. + */ +export function summarizeEntry(entry) { + let text = String(entry).trim(); + let pr = null; + + const attribution = /\s+by\s+@[\w-]+\s+in\s+https?:\/\/\S*?\/pull\/(\d+)\/?\s*$/i.exec(text); + const bareUrl = /\s+https?:\/\/\S*?\/pull\/(\d+)\/?\s*$/i.exec(text); + const parenRef = /\(#(\d+)\)\s*$/.exec(text); + + if (attribution) { + pr = Number(attribution[1]); + text = text.slice(0, attribution.index); + } else if (bareUrl) { + pr = Number(bareUrl[1]); + text = text.slice(0, bareUrl.index); + } else if (parenRef) { + pr = Number(parenRef[1]); + } + + return { + headline: truncate(firstSentence(text.replace(/\s*\(#[^)]*\)\s*$/, "")), MAX_HEADLINE), + pr, + }; +} + +// ── Payload ───────────────────────────────────────────────────────────────── + +/** + * Order the groups for display: the deprioritized ones last, everything else in + * the order the changelog introduced it. Returns `[shown, hiddenCount]`. + */ +function selectGroups(groups) { + const kept = groups.filter((g) => g.entries.length > 0); + const primary = kept.filter((g) => !DEPRIORITIZED_GROUPS.has(g.name.toLowerCase())); + // A release that is nothing but dependency bumps still gets a description. + const shown = primary.length > 0 ? primary : kept; + const hidden = kept + .filter((g) => !shown.includes(g)) + .reduce((n, g) => n + g.entries.length, 0); + return [shown, hidden]; +} + +/** + * Assemble the description within `budget`, dropping WHOLE groups rather than + * cutting mid-sentence, and always ending on the changelog link. + * + * The link is the part that must survive: everything above it is a sample, and + * the link is what makes the sample honest. The first version of this appended + * it and then truncated the finished string, which on a release the size of + * 1.0.0 cut the link off and ended the message on "Stop sending anything about + * a…" — a notification that showed a third of a release and pointed nowhere. + * + * The tail line states what was left out, counted rather than implied, so a + * reader can tell "that was everything" from "that was the first six". + * + * @param {{ + * lead: string, + * blocks: Array<{text: string, entries: number}>, + * deprioritized: number, + * changelogUrl: string, + * budget: number, + * }} options + */ +export function fitDescription({ lead, blocks, deprioritized, changelogUrl, budget }) { + const tailFor = (dropped) => { + const notes = []; + if (dropped > 0) notes.push(`${dropped} more entr${dropped === 1 ? "y" : "ies"}`); + if (deprioritized > 0) notes.push(`${deprioritized} dependency update${deprioritized === 1 ? "" : "s"}`); + const suffix = notes.length > 0 ? ` — ${notes.join(" and ")} not shown` : ""; + return `[Full changelog](${changelogUrl})${suffix}`; + }; + + const total = blocks.reduce((n, b) => n + b.entries, 0); + const parts = lead ? [lead] : []; + let used = 0; + + for (const block of blocks) { + const candidate = [...parts, block.text, tailFor(total - used - block.entries)].join("\n\n"); + if (candidate.length > budget) break; + parts.push(block.text); + used += block.entries; + } + + const out = [...parts, tailFor(total - used)].join("\n\n"); + // A lead long enough to crowd out every group is the only way to still be + // over budget here, and cutting it is preferable to dropping the link. + return out.length <= budget ? out : `${truncate(lead, Math.max(0, budget - tailFor(total).length - 2))}\n\n${tailFor(total)}`; +} + +function renderGroup(group, repo) { + const lines = [`**${group.name}**`]; + for (const entry of group.entries.slice(0, MAX_ENTRIES_PER_GROUP)) { + const link = entry.pr ? ` ([#${entry.pr}](https://github.com/${repo}/pull/${entry.pr}))` : ""; + lines.push(`• ${entry.headline}${link}`); + } + const rest = group.entries.length - MAX_ENTRIES_PER_GROUP; + if (rest > 0) lines.push(`• …and ${rest} more`); + return lines.join("\n"); +} + +/** + * Assemble the webhook body. + * + * `notes` may be null — a stable release with notes in neither source is a + * mistake `publish.yml` checks for at preflight, but the announcement still + * goes out without highlights rather than not at all. The release has already + * shipped by the time this runs; silence is the worse failure. + * + * @param {{ + * version: string, + * repo: string, + * notes: ReturnType | ReturnType, + * roleId?: string | null, + * releaseUrl?: string | null, + * timestamp?: string | null, + * }} options + */ +export function buildDiscordPayload({ + version, + repo, + notes, + roleId = null, + releaseUrl = null, + timestamp = null, +}) { + const tag = `v${version}`; + const release = releaseUrl || `https://github.com/${repo}/releases/tag/${tag}`; + const mention = roleId ? `<@&${roleId}> ` : ""; + + // Only the FIRST paragraph of a stable section's preamble. 1.0.0's runs to + // four, and flattening them all into the description produced a wall of prose + // above the entries anybody opened the message to read. + const lead = notes?.lead ? truncate(notes.lead.split(/\n\s*\n/)[0].replace(/\s*\n\s*/g, " "), MAX_LEAD) : ""; + const [shown, deprioritized] = notes ? selectGroups(notes.groups) : [[], 0]; + + // A generated release body ends on a compare link, which says more than a + // blob link to the file does. Without one, the changelog at this tag. + const changelogUrl = notes?.compareUrl || `https://github.com/${repo}/blob/${tag}/CHANGELOG.md`; + const blocks = shown.map((g) => ({ text: renderGroup(g, repo), entries: g.entries.length })); + + const description = fitDescription({ + lead, + blocks, + deprioritized, + changelogUrl, + budget: LIMIT_DESCRIPTION, + }); + + const embed = { + title: truncate(`failproofai ${tag}`, LIMIT_TITLE), + url: release, + color: BRAND_COLOR, + description, + fields: [ + { + name: "Install", + value: truncate("```sh\nnpm install -g failproofai\n```", LIMIT_FIELD_VALUE), + }, + { + name: "Links", + value: truncate( + [ + `[Release notes](${release})`, + `[npm](https://www.npmjs.com/package/failproofai/v/${version})`, + "[Docs](https://docs.befailproof.ai/)", + ].join(" · "), + LIMIT_FIELD_VALUE, + ), + }, + ], + footer: { text: `published from ${repo}` }, + }; + if (timestamp) embed.timestamp = timestamp; + + // Discord's 6000-character ceiling is over the SUM of an embed's text fields, + // which no single limit above covers. Everything but the description is fixed + // and small, so the description is re-fitted against what is left rather than + // chopped — same rule as above, the changelog link outlives the cut. + const fixed = embed.title.length + embed.footer.text.length + embed.fields.reduce((n, f) => n + f.name.length + f.value.length, 0); + if (fixed + embed.description.length > LIMIT_EMBED_TOTAL) { + embed.description = fitDescription({ + lead, + blocks, + deprioritized, + changelogUrl, + budget: Math.max(0, LIMIT_EMBED_TOTAL - fixed), + }); + } + + return { + content: truncate(`${mention}**failproofai ${tag}** is out.`, LIMIT_CONTENT), + // `parse: []` is the guard, not the roles list: it stops @everyone/@here + // and any user mention that changelog prose happens to contain. The role + // is then re-allowed by id, so exactly one thing in this message pings. + allowed_mentions: { parse: [], roles: roleId ? [String(roleId)] : [] }, + embeds: [embed], + }; +} + +// ── CLI ───────────────────────────────────────────────────────────────────── + +/** `plural(1, "entry", "entries")` -> `"1 entry"`. */ +function plural(n, one, many) { + return `${n} ${n === 1 ? one : many}`; +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const eq = arg.indexOf("="); + if (eq !== -1) out[arg.slice(2, eq)] = arg.slice(eq + 1); + else out[arg.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true"; + } + return out; +} + +export function main(argv = process.argv.slice(2), io = console) { + const args = parseArgs(argv); + const version = args.version; + const repo = args.repo || process.env.GITHUB_REPOSITORY || "failproof-ai/failproofai"; + if (!version) { + io.error( + "usage: release-announcement.mjs --version [--repo owner/name] [--notes-file FILE] " + + "[--role-id ID] [--release-url URL] [--out FILE] [--check]", + ); + process.exitCode = 1; + return null; + } + + // The release body arrives as a FILE, never as an argument. It is arbitrary + // markdown a human typed into a web form, and putting it on a command line + // (or into a shell string) is how a backtick in someone's release notes + // becomes command substitution in the release pipeline. + const notesPath = args["notes-file"] && args["notes-file"] !== "true" ? args["notes-file"] : null; + const releaseBody = notesPath && existsSync(notesPath) ? readFileSync(notesPath, "utf8") : ""; + + const changelogPath = args.changelog || resolve(REPO_ROOT, "CHANGELOG.md"); + const changelog = existsSync(changelogPath) ? readFileSync(changelogPath, "utf8") : ""; + const notes = chooseNotes({ releaseBody, changelog, version }); + const source = parseReleaseBody(releaseBody) ? "the GitHub Release body" : `${changelogPath}`; + + // `--check` is publish.yml's preflight gate: it answers "will this release + // have anything to announce" before anything is built, which is the one point + // in the pipeline where the answer "no" is nearly free to act on. + if (args.check === "true") { + if (!notes) { + io.error( + `::error file=CHANGELOG.md::Nothing to announce for ${version}: the GitHub Release body is ` + + `empty and CHANGELOG.md has no section for ${version} (or any ${version}-* prerelease). ` + + `A stable release announces itself in Discord, so it would post with no release notes in ` + + `it. Write the release notes, or add the changelog section, and re-run.`, + ); + process.exitCode = 1; + return null; + } + io.error(`${version} has release notes (${plural(notes.total, "entry", "entries")}, from ${source}).`); + return notes; + } + + if (!notes) { + // A warning rather than an error: the release is already on npm by the + // time this runs, and an announcement with no highlights still tells + // people the version exists and how to install it. + io.error(`::warning::No release notes for ${version} in the release body or ${changelogPath} — announcing without highlights.`); + } else { + io.error(`Collected ${plural(notes.total, "entry", "entries")} from ${source}.`); + } + + const payload = buildDiscordPayload({ + version, + repo, + notes, + roleId: args["role-id"] && args["role-id"] !== "true" ? args["role-id"] : null, + releaseUrl: args["release-url"] && args["release-url"] !== "true" ? args["release-url"] : null, + timestamp: args.timestamp && args.timestamp !== "true" ? args.timestamp : new Date().toISOString(), + }); + + const json = JSON.stringify(payload, null, 2); + if (args.out && args.out !== "true") { + writeFileSync(args.out, json); + io.error(`Wrote ${args.out} (${json.length} bytes).`); + } else { + io.log(json); + } + return payload; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +}