From 4450a4ba61b369e67b27d7ad8e01f108c01d10b0 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Thu, 24 Sep 2026 14:35:12 +0700 Subject: [PATCH 1/2] Add ID generator, and automate releases from the changelog ID generator - Prefixed identifiers in the Stripe shape (sk_live_4eC39HqL...): prefix, separator, random length, alphabet and batch size are all configurable. - Doubles as a password generator via a symbol alphabet plus an option to drop confusable glyphs (0/O, 1/l/I), which matters for anything a human retypes and is pointless for a machine token -- hence optional rather than default. - Live entropy readout with a qualitative verdict. Deliberately no "time to crack" figure: it depends entirely on assumed hardware and on whether the value is hashed, so quoting one would be false precision presented as a guarantee. - Entropy counts the random portion only. The prefix is public by design and contributes none. - Rejection sampling, never `value % n`, which biases toward the start of the alphabet whenever the size does not divide 2^32. A distribution test would fail if that regressed. - Settings persist; generated values live in component state and are never written to disk under any code path. - The unbiased randomness primitives move to src/lib/random.ts now that the list shuffler and this tool both need them. Release automation - CI gates every pull request on typecheck, lint, tests, build, and an entry bundle budget, so a heavy dependency escaping a lazy chunk fails the build rather than reaching production. - Merging to main does not deploy. It derives the next version from the [Unreleased] section of CHANGELOG.md, rewrites the changelog, commits that back to main and pushes a tag. The tag is what deploys, so merging and releasing stay separate decisions and an empty [Unreleased] ships nothing. - The bump logic is a tested pure module rather than inline shell, because a mistake there silently ships the wrong version and a green run would not reveal it. `### Removed` maps to patch, not major, so a tidy-up cannot silently become a 1.0; major requires an explicit `### Breaking`. - A tag pushed with the default GITHUB_TOKEN does not trigger other workflows, by design. With a PAT in RELEASE_TOKEN the tag triggers Deploy natively; without one the workflow starts Deploy explicitly, so this works either way. --- .github/workflows/ci.yml | 58 ++++ .github/workflows/deploy.yml | 73 +++++ .github/workflows/release.yml | 88 ++++++ CHANGELOG.md | 32 +- CONTRIBUTING.md | 43 +++ scripts/changelog.test.ts | 138 +++++++++ scripts/changelog.ts | 119 ++++++++ scripts/release.ts | 60 ++++ .../list/core/shuffle.ts => lib/random.ts} | 3 +- src/lib/registry/index.ts | 32 +- src/tools/id-gen/IdGenTool.tsx | 275 ++++++++++++++++++ src/tools/id-gen/core/generate.test.ts | 234 +++++++++++++++ src/tools/id-gen/core/generate.ts | 219 ++++++++++++++ src/tools/list/core/pipeline.ts | 2 +- vitest.config.ts | 2 +- 15 files changed, 1373 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/changelog.test.ts create mode 100644 scripts/changelog.ts create mode 100644 scripts/release.ts rename src/{tools/list/core/shuffle.ts => lib/random.ts} (95%) create mode 100644 src/tools/id-gen/IdGenTool.tsx create mode 100644 src/tools/id-gen/core/generate.test.ts create mode 100644 src/tools/id-gen/core/generate.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0df2b91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +# Cancel superseded runs on the same ref: only the latest push matters. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: Typecheck, lint, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + # Fails loudly if the lockfile has drifted, which is the point of pnpm here. + - run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + # The entry bundle is what every visitor pays for on first load. If a + # heavy dependency escapes a lazy route chunk, this is where we find out + # rather than in production. + - name: Check entry bundle budget + run: | + BUDGET=92160 + ENTRY=$(find dist/assets -name 'index-*.js' -print -quit) + SIZE=$(gzip -c "$ENTRY" | wc -c | tr -d ' ') + echo "entry: $ENTRY" + echo "gzipped: ${SIZE}B budget: ${BUDGET}B" + if [ "$SIZE" -gt "$BUDGET" ]; then + echo "::error::Entry bundle ${SIZE}B exceeds the ${BUDGET}B budget." + exit 1 + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f2e9bdd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,73 @@ +name: Deploy + +# Deploys a TAG, never a branch: whatever shipped is always reachable by +# version. Normally started by the Release workflow after it tags a merge to +# main; a hand-pushed tag works too. +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to deploy, e.g. v0.2.0' + required: true + type: string + +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Build and deploy to Cloudflare + runs-on: ubuntu-latest + environment: + name: production + url: https://devtools.fadeltd.dev + steps: + - uses: actions/checkout@v4 + with: + # Deploy exactly the tagged commit, whichever way this was started. + ref: ${{ inputs.tag || github.ref }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Cloudflare would happily serve a broken build, so nothing ships that + # has not passed the same gate CI applies to pull requests. + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Deploy + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy + + - name: Smoke check the live site + run: | + sleep 10 + for path in / /id-gen /json /diff /base64 /list /text-stats; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://devtools.fadeltd.dev${path}") + echo "${path} -> ${code}" + [ "${code}" = "200" ] || { echo "::error::${path} returned ${code}"; exit 1; } + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98cfa0c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +# Merging to main does not deploy directly. It derives the next version from +# the [Unreleased] section of CHANGELOG.md, rewrites the changelog, commits +# that back to main, and pushes a tag. The TAG is what deploys. +# +# A merge with an empty [Unreleased] releases nothing, so merging and releasing +# stay separate decisions. +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + actions: write + +jobs: + release: + name: Cut a release from the changelog + runs-on: ubuntu-latest + # Never react to our own release commit. + if: "!contains(github.event.head_commit.message, '[skip ci]')" + env: + # A tag pushed with GITHUB_TOKEN does NOT trigger other workflows, by + # design, to prevent recursion. With a PAT in RELEASE_TOKEN the tag + # triggers Deploy natively; without one we start Deploy explicitly. + HAS_PAT: ${{ secrets.RELEASE_TOKEN != '' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - id: cut + name: Derive the version from CHANGELOG.md + run: pnpm exec vite-node scripts/release.ts + + - name: Commit the changelog and tag + if: steps.cut.outputs.released == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md package.json + # [skip ci] so this push cannot re-enter this workflow when a PAT is + # in use -- with a PAT, pushes DO trigger workflows. + git commit -m "Release ${{ steps.cut.outputs.tag }} [skip ci]" + git tag -a "${{ steps.cut.outputs.tag }}" -m "${{ steps.cut.outputs.tag }}" + git push origin HEAD:main + git push origin "${{ steps.cut.outputs.tag }}" + + - name: Create the GitHub release + if: steps.cut.outputs.released == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.cut.outputs.tag }}" \ + --title "${{ steps.cut.outputs.tag }}" \ + --notes "${{ steps.cut.outputs.notes }}" + + # Only needed while no PAT is configured; with one, the tag push above + # has already started Deploy. + - name: Start Deploy (no PAT configured) + if: steps.cut.outputs.released == 'true' && env.HAS_PAT != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "::notice::No RELEASE_TOKEN set, so the tag cannot trigger Deploy. Dispatching it directly." + gh workflow run deploy.yml --ref main -f tag="${{ steps.cut.outputs.tag }}" + + - name: Summary + run: | + if [ "${{ steps.cut.outputs.released }}" = "true" ]; then + echo "Released ${{ steps.cut.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" + else + echo "No [Unreleased] entries — nothing released." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4754f04..39aba8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,37 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -Nothing yet. +### Added +- **ID Generator** — prefixed identifiers in the Stripe style + (`sk_live_` plus 24 random characters): set a prefix, separator, length, + alphabet and batch size. Doubles as a password generator via a symbol + alphabet and an + option to drop confusable glyphs (`0`/`O`, `1`/`l`/`I`). + - Live entropy readout with a qualitative strength verdict. Deliberately no + "time to crack" figure: that depends entirely on assumed hardware and on + whether the value is hashed, so quoting one would be false precision. + - Presets for Stripe secret and test keys, object ids, API tokens, hex + session ids, passwords and human-readable codes. + - Values come from `crypto.getRandomValues` with rejection sampling, never + `value % n`, which biases toward the start of the alphabet. + - Settings persist; **generated values never do**. +- Collapsible sidebar, kept as an icon rail with accessible names intact. +- Link to the GitHub repository in the header. +- Continuous integration on every pull request, including an entry-bundle size + budget so a heavy dependency escaping a lazy chunk fails the build rather + than reaching production. +- Release automation: merging to `main` creates a version tag, and the **tag** + is what deploys. A merge that does not change the version in `package.json` + releases nothing, so merging and releasing are separate decisions. + +### Fixed +- Focus rings on full-bleed text areas were drawn outside the element and + clipped by the surrounding pane, so only the top and right edges were + visible. They are now drawn inset. + +### Changed +- Unbiased randomness primitives moved to `src/lib/random.ts`, now shared by + the list shuffler and the ID generator. ## [0.1.0] — 2026-09-24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e21838c..e7baee7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,49 @@ Small, focused PRs. If you are planning something large, open an issue first — the project has a deliberate list of things it will not do, and it would be a shame for you to build one of them. +## Releases + +**You never pick a version number, and you never write a release date.** + +While you work, add your entry under `## [Unreleased]` in `CHANGELOG.md`, using +a Keep a Changelog heading: + +```markdown +## [Unreleased] + +### Added +- The thing you added +``` + +Merging to `main` then does the rest, automatically: + +1. Reads `[Unreleased]` and derives the semver bump from its headings — + `### Breaking` → major, `### Added` → minor, anything else + (`Fixed`, `Changed`, `Security`, `Removed`) → patch. +2. Rewrites `CHANGELOG.md`, moving `[Unreleased]` into a dated + `## [x.y.z]` section and leaving a fresh empty `[Unreleased]` behind. +3. Bumps `version` in `package.json`, commits that back to `main`, and pushes + the tag `vx.y.z`. +4. **The tag** triggers the deploy to Cloudflare. Merging alone never deploys. + +An empty `[Unreleased]` releases nothing, so a docs-only or refactor merge ships +nothing. That is deliberate: merging and releasing are separate decisions. + +Two notes for anyone editing the workflows: + +- `### Removed` is deliberately a *patch*, not a major. Inferring a major bump + from a tidy-up would let a cleanup silently become a 1.0. Major requires an + explicit `### Breaking` heading. +- Below 1.0, a breaking change bumps the minor (`0.4.2` → `0.5.0`), per semver + convention for pre-stable projects. + +The bump logic lives in `scripts/changelog.ts` and is unit-tested, because a +mistake there silently ships the wrong version. Preview what a merge would do: + +```bash +pnpm exec vite-node scripts/release.ts --dry-run +``` + ## Licence By contributing you agree your contributions are licensed under the MIT diff --git a/scripts/changelog.test.ts b/scripts/changelog.test.ts new file mode 100644 index 0000000..edd2e09 --- /dev/null +++ b/scripts/changelog.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + applyRelease, + determineBump, + nextVersion, + parseUnreleased, + parseVersion, +} from './changelog' + +const doc = (unreleased: string, rest = '## [0.1.0] — 2026-01-01\n\n### Added\n- first\n') => + `# Changelog\n\nBlurb.\n\n## [Unreleased]\n\n${unreleased}\n\n${rest}` + +describe('parseUnreleased', () => { + it('finds sections and bullets', () => { + const u = parseUnreleased(doc('### Added\n- a thing\n\n### Fixed\n- a bug')) + expect(u.sections).toEqual(['Added', 'Fixed']) + expect(u.hasContent).toBe(true) + }) + + it('treats filler as no content', () => { + expect(parseUnreleased(doc('Nothing yet.')).hasContent).toBe(false) + }) + + it('treats a heading with no bullets as no content', () => { + expect(parseUnreleased(doc('### Added')).hasContent).toBe(false) + }) + + it('does not run past the next release heading', () => { + const u = parseUnreleased(doc('### Fixed\n- mine')) + expect(u.body).not.toContain('first') + expect(u.sections).toEqual(['Fixed']) + }) + + it('handles a changelog with no Unreleased section', () => { + const u = parseUnreleased('# Changelog\n\n## [0.1.0] — 2026-01-01\n') + expect(u.hasContent).toBe(false) + }) +}) + +describe('determineBump', () => { + const bumpOf = (body: string) => determineBump(parseUnreleased(doc(body))) + + it('is none when there is nothing to release', () => { + expect(bumpOf('Nothing yet.')).toBe('none') + }) + + it('is minor for Added', () => { + expect(bumpOf('### Added\n- feature')).toBe('minor') + }) + + it('is patch for Fixed, Changed or Security alone', () => { + expect(bumpOf('### Fixed\n- bug')).toBe('patch') + expect(bumpOf('### Changed\n- tweak')).toBe('patch') + expect(bumpOf('### Security\n- hardening')).toBe('patch') + }) + + it('is major only when Breaking is explicit', () => { + expect(bumpOf('### Breaking\n- dropped an API')).toBe('major') + // Removed must NOT imply major: a tidy-up should not force a 1.0. + expect(bumpOf('### Removed\n- dead code')).toBe('patch') + }) + + it('takes the highest applicable bump', () => { + expect(bumpOf('### Fixed\n- bug\n\n### Added\n- feature')).toBe('minor') + expect(bumpOf('### Added\n- feature\n\n### Breaking\n- gone')).toBe('major') + }) +}) + +describe('nextVersion', () => { + it('bumps each component', () => { + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') + expect(nextVersion('1.2.3', 'major')).toBe('2.0.0') + }) + + it('keeps 0.x pre-stable: a breaking change is a minor bump', () => { + expect(nextVersion('0.4.2', 'major')).toBe('0.5.0') + }) + + it('returns the current version for no bump', () => { + expect(nextVersion('1.2.3', 'none')).toBe('1.2.3') + }) + + it('rejects non-semver input rather than guessing', () => { + expect(() => parseVersion('1.2')).toThrow() + expect(() => parseVersion('v1.2.3')).toThrow() + }) +}) + +describe('applyRelease', () => { + const today = '2026-09-24' + + it('returns null when there is nothing to release', () => { + expect(applyRelease(doc('Nothing yet.'), '0.1.0', today)).toBeNull() + }) + + it('moves Unreleased into a dated section and computes the version', () => { + const r = applyRelease(doc('### Added\n- a feature'), '0.1.0', today) + expect(r).not.toBeNull() + expect(r!.version).toBe('0.2.0') + expect(r!.bump).toBe('minor') + expect(r!.changelog).toContain(`## [0.2.0] — ${today}`) + expect(r!.changelog).toContain('- a feature') + }) + + it('leaves a fresh empty Unreleased behind', () => { + const r = applyRelease(doc('### Fixed\n- bug'), '0.1.0', today)! + expect(parseUnreleased(r.changelog).hasContent).toBe(false) + expect(r.changelog).toMatch(/## \[Unreleased\]\s*\n\s*Nothing yet\./) + }) + + it('preserves earlier releases', () => { + const r = applyRelease(doc('### Fixed\n- bug'), '0.1.0', today)! + expect(r.changelog).toContain('## [0.1.0] — 2026-01-01') + expect(r.changelog).toContain('- first') + }) + + it('orders the new release above the previous one', () => { + const r = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(r.changelog.indexOf('## [0.2.0]')).toBeLessThan(r.changelog.indexOf('## [0.1.0]')) + }) + + it('returns the notes for the release body', () => { + const r = applyRelease(doc('### Added\n- a feature'), '0.1.0', today)! + expect(r.notes).toContain('- a feature') + expect(r.notes).not.toContain('first') + }) + + it('is idempotent: running it again releases nothing', () => { + const first = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(applyRelease(first.changelog, first.version, today)).toBeNull() + }) + + it('does not leave runs of blank lines', () => { + const r = applyRelease(doc('### Added\n- x'), '0.1.0', today)! + expect(r.changelog).not.toMatch(/\n{4,}/) + }) +}) diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 0000000..de34986 --- /dev/null +++ b/scripts/changelog.ts @@ -0,0 +1,119 @@ +/** + * Release logic, derived from CHANGELOG.md. + * + * Pure and tested, because a mistake here silently ships the wrong version or + * loses release notes, and neither is obvious from a green workflow run. + */ + +export type Bump = 'major' | 'minor' | 'patch' | 'none' + +export interface Unreleased { + /** The section bodies under `## [Unreleased]`, verbatim. */ + body: string + /** Heading names found, e.g. ['Added', 'Fixed']. */ + sections: string[] + hasContent: boolean +} + +const UNRELEASED_HEADING = /^## \[Unreleased\]\s*$/m +const ANY_RELEASE_HEADING = /^## \[\d+\.\d+\.\d+\]/m + +/** Extract the Unreleased block, ignoring filler like "Nothing yet." */ +export function parseUnreleased(changelog: string): Unreleased { + const start = changelog.search(UNRELEASED_HEADING) + if (start === -1) return { body: '', sections: [], hasContent: false } + + const afterHeading = changelog.indexOf('\n', start) + 1 + const rest = changelog.slice(afterHeading) + const nextRelease = rest.search(ANY_RELEASE_HEADING) + const body = (nextRelease === -1 ? rest : rest.slice(0, nextRelease)).trim() + + const sections = [...body.matchAll(/^### (.+?)\s*$/gm)].map((m) => m[1]!.trim()) + + // A section heading with no bullets under it is not content. + const hasBullets = /^[-*] /m.test(body) + + return { body, sections, hasContent: sections.length > 0 && hasBullets } +} + +/** + * Map Keep a Changelog sections to a semver bump. + * + * `Breaking` must be explicit — inferring a major from `Removed` would let a + * tidy-up silently become a 1.0, which is exactly the surprise this should + * avoid. + */ +export function determineBump(u: Unreleased): Bump { + if (!u.hasContent) return 'none' + const lower = u.sections.map((s) => s.toLowerCase()) + if (lower.includes('breaking') || lower.includes('breaking changes')) return 'major' + if (lower.includes('added')) return 'minor' + return 'patch' +} + +export function parseVersion(version: string): [number, number, number] { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()) + if (!m) throw new Error(`Not a semver version: "${version}"`) + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function nextVersion(current: string, bump: Bump): string { + const [major, minor, patch] = parseVersion(current) + switch (bump) { + case 'major': + // Stay in 0.x until the author decides to commit to a stable API: + // a breaking change pre-1.0 is a minor bump by convention. + return major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0` + case 'minor': + return `${major}.${minor + 1}.0` + case 'patch': + return `${major}.${minor}.${patch + 1}` + case 'none': + return current + } +} + +export interface ReleaseResult { + changelog: string + version: string + bump: Bump + notes: string +} + +/** + * Move the Unreleased block into a dated release section and leave a fresh + * empty Unreleased behind. Returns the new file contents; writes nothing. + */ +export function applyRelease( + changelog: string, + currentVersion: string, + today: string, +): ReleaseResult | null { + const unreleased = parseUnreleased(changelog) + const bump = determineBump(unreleased) + if (bump === 'none') return null + + const version = nextVersion(currentVersion, bump) + + const start = changelog.search(UNRELEASED_HEADING) + const afterHeading = changelog.indexOf('\n', start) + 1 + const head = changelog.slice(0, afterHeading) + const rest = changelog.slice(afterHeading) + const nextRelease = rest.search(ANY_RELEASE_HEADING) + const tail = nextRelease === -1 ? '' : rest.slice(nextRelease) + + const rebuilt = + head + + '\nNothing yet.\n\n' + + `## [${version}] — ${today}\n\n` + + unreleased.body + + '\n\n' + + tail + + return { + changelog: rebuilt.replace(/\n{4,}/g, '\n\n\n').trimEnd() + '\n', + version, + bump, + notes: unreleased.body, + } +} diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..38604b6 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,60 @@ +/** + * Cut a release from CHANGELOG.md. + * + * Reads the Unreleased section, decides the semver bump from its headings, + * rewrites CHANGELOG.md and package.json, and prints the outcome for the + * workflow to consume. Writes files but never touches git — the workflow owns + * committing, tagging and pushing, so this stays runnable locally as a dry run. + * + * pnpm exec vite-node scripts/release.ts --dry-run + */ +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +import { applyRelease } from './changelog' + +const dryRun = process.argv.includes('--dry-run') + +const changelogPath = 'CHANGELOG.md' +const packagePath = 'package.json' + +const changelog = readFileSync(changelogPath, 'utf8') +const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as { version: string } +const today = new Date().toISOString().slice(0, 10) + +const result = applyRelease(changelog, pkg.version, today) + +function emit(key: string, value: string): void { + const out = process.env.GITHUB_OUTPUT + if (out) appendFileSync(out, `${key}=${value}\n`) +} + +if (!result) { + console.log('Nothing under [Unreleased] — no release.') + emit('released', 'false') + process.exit(0) +} + +console.log(`bump : ${result.bump}`) +console.log(`version : ${pkg.version} -> ${result.version}`) +console.log(`tag : v${result.version}`) + +if (dryRun) { + console.log('\n--- notes ---') + console.log(result.notes) + console.log('\n(dry run: no files written)') + process.exit(0) +} + +writeFileSync(changelogPath, result.changelog) +writeFileSync(packagePath, JSON.stringify({ ...pkg, version: result.version }, null, 2) + '\n') + +emit('released', 'true') +emit('version', result.version) +emit('tag', `v${result.version}`) + +// Release notes can be multi-line, so they need the delimiter form. +const out = process.env.GITHUB_OUTPUT +if (out) { + appendFileSync(out, `notes< import('@/tools/id-gen/IdGenTool'), + status: 'beta', + }, { slug: 'json', title: 'JSON Formatter', diff --git a/src/tools/id-gen/IdGenTool.tsx b/src/tools/id-gen/IdGenTool.tsx new file mode 100644 index 0000000..efee17d --- /dev/null +++ b/src/tools/id-gen/IdGenTool.tsx @@ -0,0 +1,275 @@ +import { useCallback, useState } from 'react' +import { RefreshCw, ShieldCheck } from 'lucide-react' +import { ToolFrame } from '@/components/layout/ToolFrame' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { CodeArea } from '@/components/ui/CodeArea' +import { CopyButton } from '@/components/ui/CopyButton' +import { Select, Toggle } from '@/components/ui/Select' +import { useToolState } from '@/lib/persist/useToolState' +import { useShortcuts } from '@/lib/keys/useShortcuts' +import { useToolUsageTracker } from '@/lib/prefs' +import { copyText } from '@/lib/util/clipboard' +import { formatCount } from '@/lib/util/bytes' +import { + DEFAULT_OPTIONS, + MAX_COUNT, + MAX_LENGTH, + PRESETS, + generateIds, + strength, + type AlphabetName, + type IdOptions, +} from './core/generate' + +const ALPHABET_LABELS: Record = { + alphanumeric: 'Alphanumeric (A–Z a–z 0–9)', + lowercase: 'Lowercase + digits', + uppercase: 'Uppercase + digits', + letters: 'Letters only', + numeric: 'Digits only', + hex: 'Hexadecimal', + base58: 'Base58 (no 0 O I l)', + password: 'Alphanumeric + symbols', + custom: 'Custom…', +} + +const ERRORS: Record = { + 'empty-alphabet': 'The alphabet is empty. Add characters, or turn off “exclude ambiguous”.', + 'invalid-length': `Length must be a whole number between 1 and ${MAX_LENGTH}.`, + 'invalid-count': `Count must be a whole number between 1 and ${MAX_COUNT}.`, +} + +export default function IdGenTool() { + useToolUsageTracker('id-gen') + + // Only the OPTIONS persist. Generated values are secrets and live in + // component state, so they are never written to disk under any code path. + const { state, setState, reset } = useToolState('id-gen', DEFAULT_OPTIONS) + + // Generating is an action, not a derivation -- both changing an option and + // pressing Generate are user events, so the result is state updated from + // those handlers rather than a memo with an artificial dependency. + const [result, setResult] = useState(() => generateIds(state)) + + const regenerate = useCallback(() => setResult(generateIds(state)), [state]) + + const applyOptions = useCallback( + (update: (prev: IdOptions) => IdOptions) => { + setState((prev) => { + const next = update(prev) + setResult(generateIds(next)) + return next + }) + }, + [setState], + ) + + const ids = result.ids + const output = ids.join('\n') + const verdict = strength(result.entropyBits) + + useShortcuts([ + { + combo: 'mod+enter', + label: 'Generate again', + group: 'ID generator', + scope: 'tool', + whileTyping: true, + run: regenerate, + }, + { + combo: 'mod+shift+c', + label: 'Copy all', + group: 'ID generator', + scope: 'tool', + whileTyping: true, + run: () => void copyText(output), + }, + ]) + + const set = (key: K, value: IdOptions[K]) => + applyOptions((p) => ({ ...p, [key]: value })) + + const field = 'min-h-11 w-full rounded-[4px] border border-border bg-bg px-2 text-fg md:min-h-7' + + return ( + + + + + + + } + > +
+
+
+ + + + + + + + + {state.alphabet === 'custom' && ( + + )} + + + +
+ set('excludeAmbiguous', v)} + > + No 0/O, 1/l/I + +
+
+ +
+
+ Entropy + + {result.entropyBits.toFixed(0)} bits + +
+
+ + + + + {verdict.label} + +
+

{verdict.detail}

+

+ Drawn from {formatCount(result.alphabetSize)} characters. The prefix adds no + entropy — it is public by design. +

+
+ +

+ + Generated with crypto.getRandomValues and unbiased + sampling. Your settings are saved; the generated values never are. +

+
+ +
+ {result.error !== undefined ? ( +
+ {ERRORS[result.error] ?? 'Invalid options.'} +
+ ) : ( + + )} +
+ {formatCount(ids.length)} generated + {ids[0] !== undefined && {ids[0].length} characters each} +
+
+
+
+ ) +} diff --git a/src/tools/id-gen/core/generate.test.ts b/src/tools/id-gen/core/generate.test.ts new file mode 100644 index 0000000..ed92fa4 --- /dev/null +++ b/src/tools/id-gen/core/generate.test.ts @@ -0,0 +1,234 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { mulberry32 } from '@/lib/random' +import { + ALPHABETS, + DEFAULT_OPTIONS, + MAX_COUNT, + MAX_LENGTH, + PRESETS, + entropyBits, + generateIds, + resolveAlphabet, + strength, + type IdOptions, +} from './generate' + +const opts = (over: Partial = {}): IdOptions => ({ ...DEFAULT_OPTIONS, ...over }) + +describe('resolveAlphabet', () => { + it('returns the named alphabet', () => { + expect(resolveAlphabet({ alphabet: 'hex', excludeAmbiguous: false })).toBe('0123456789abcdef') + }) + + it('drops ambiguous glyphs when asked', () => { + const a = resolveAlphabet({ alphabet: 'alphanumeric', excludeAmbiguous: true }) + for (const ch of ['O', '0', 'o', 'I', 'l', '1']) expect(a).not.toContain(ch) + expect(a).toContain('A') + }) + + it('deduplicates, so a repeated character cannot skew the distribution', () => { + const a = resolveAlphabet({ alphabet: 'custom', customAlphabet: 'aaabbc', excludeAmbiguous: false }) + expect(a).toBe('abc') + }) + + it('base58 already excludes the confusable glyphs', () => { + for (const ch of ['0', 'O', 'I', 'l']) expect(ALPHABETS.base58).not.toContain(ch) + }) +}) + +describe('generateIds', () => { + it('produces the requested count', () => { + expect(generateIds(opts({ count: 7 })).ids).toHaveLength(7) + }) + + it('builds the Stripe shape', () => { + const ids = generateIds(opts({ prefix: 'sk_live', separator: '_', length: 24, count: 1 })).ids + expect(ids[0]).toMatch(/^sk_live_[A-Za-z0-9]{24}$/) + }) + + it('omits the separator when there is no prefix', () => { + const ids = generateIds(opts({ prefix: '', separator: '_', length: 8, count: 1 })).ids + expect(ids[0]).toMatch(/^[A-Za-z0-9]{8}$/) + }) + + it('measures length over the random portion only', () => { + const ids = generateIds(opts({ prefix: 'cus', separator: '_', length: 10, count: 1 })).ids + expect(ids[0]?.slice('cus_'.length)).toHaveLength(10) + }) + + it('only ever emits characters from the alphabet', () => { + const alphabet = resolveAlphabet({ alphabet: 'hex', excludeAmbiguous: false }) + for (const id of generateIds(opts({ prefix: '', separator: '', alphabet: 'hex', count: 50 })).ids) { + for (const ch of id) expect(alphabet).toContain(ch) + } + }) + + it('does not repeat itself across a batch', () => { + const { ids } = generateIds(opts({ count: 200, length: 24 })) + expect(new Set(ids).size).toBe(200) + }) + + it('is reproducible when given a seeded source', () => { + const a = generateIds(opts({ count: 5 }), mulberry32(42)).ids + const b = generateIds(opts({ count: 5 }), mulberry32(42)).ids + const c = generateIds(opts({ count: 5 }), mulberry32(43)).ids + expect(a).toEqual(b) + expect(a).not.toEqual(c) + }) + + it('does not throw on a large batch', () => { + // crypto.getRandomValues throws above 64 KiB per call; the chunked source + // is what keeps this safe. + expect(() => generateIds(opts({ count: MAX_COUNT, length: 64 }))).not.toThrow() + }) + + describe('validation', () => { + it('rejects an empty alphabet rather than producing empty ids', () => { + const r = generateIds(opts({ alphabet: 'custom', customAlphabet: '' })) + expect(r.error).toBe('empty-alphabet') + expect(r.ids).toEqual([]) + }) + + it('rejects an alphabet emptied by the ambiguity filter', () => { + expect( + generateIds(opts({ alphabet: 'custom', customAlphabet: '0O1l', excludeAmbiguous: true })) + .error, + ).toBe('empty-alphabet') + }) + + it('rejects out-of-range lengths and counts', () => { + expect(generateIds(opts({ length: 0 })).error).toBe('invalid-length') + expect(generateIds(opts({ length: MAX_LENGTH + 1 })).error).toBe('invalid-length') + expect(generateIds(opts({ length: 1.5 })).error).toBe('invalid-length') + expect(generateIds(opts({ count: 0 })).error).toBe('invalid-count') + expect(generateIds(opts({ count: MAX_COUNT + 1 })).error).toBe('invalid-count') + }) + }) +}) + +describe('entropyBits', () => { + it('is length times log2(alphabet size)', () => { + expect(entropyBits(16, 32)).toBe(128) // hex: 4 bits per char + expect(entropyBits(64, 10)).toBe(60) + }) + + it('is zero for a degenerate alphabet', () => { + expect(entropyBits(1, 100)).toBe(0) + expect(entropyBits(62, 0)).toBe(0) + }) + + it('matches the value reported by generateIds', () => { + const r = generateIds(opts({ alphabet: 'hex', length: 32, prefix: '', separator: '' })) + expect(r.entropyBits).toBe(128) + expect(r.alphabetSize).toBe(16) + }) + + it('counts only the random portion — the prefix is public', () => { + const withPrefix = generateIds(opts({ prefix: 'sk_live', length: 24 })) + const without = generateIds(opts({ prefix: '', separator: '', length: 24 })) + expect(withPrefix.entropyBits).toBe(without.entropyBits) + }) +}) + +describe('strength', () => { + it('bands entropy sensibly', () => { + expect(strength(40).level).toBe('weak') + expect(strength(70).level).toBe('fair') + expect(strength(128).level).toBe('strong') + expect(strength(400).level).toBe('excessive') + }) + + it('always explains itself', () => { + for (const bits of [0, 63, 64, 79, 80, 256, 257, 1000]) { + expect(strength(bits).detail.length).toBeGreaterThan(10) + } + }) + + it('rates the Stripe default as strong', () => { + // 24 alphanumeric characters is ~143 bits. + expect(strength(generateIds(DEFAULT_OPTIONS).entropyBits).level).toBe('strong') + }) +}) + +describe('presets', () => { + it('all produce valid output', () => { + for (const preset of PRESETS) { + const r = generateIds(opts(preset.options)) + expect(r.error, preset.name).toBeUndefined() + expect(r.ids.length, preset.name).toBeGreaterThan(0) + } + }) + + it('the password preset is strong and avoids confusable glyphs', () => { + const preset = PRESETS.find((p) => p.name === 'Password')! + const r = generateIds(opts(preset.options)) + expect(strength(r.entropyBits).level).toBe('strong') + for (const id of r.ids) { + for (const ch of ['O', '0', 'l', '1', 'I']) expect(id).not.toContain(ch) + } + }) +}) + +describe('properties', () => { + it('every id has exactly prefix + separator + length characters', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z_]{0,8}$/), + fc.integer({ min: 1, max: 40 }), + fc.integer({ min: 1, max: 5 }), + (prefix, length, count) => { + const r = generateIds(opts({ prefix, separator: '_', length, count })) + const head = prefix === '' ? 0 : prefix.length + 1 + for (const id of r.ids) expect(id).toHaveLength(head + length) + }, + ), + { numRuns: 150 }, + ) + }) + + it('never emits a character outside the resolved alphabet', () => { + fc.assert( + fc.property( + fc.constantFrom('alphanumeric', 'hex', 'base58', 'numeric', 'password'), + fc.boolean(), + (alphabet, excludeAmbiguous) => { + const o = opts({ + alphabet: alphabet as IdOptions['alphabet'], + excludeAmbiguous, + prefix: '', + separator: '', + count: 5, + length: 32, + }) + const allowed = new Set(resolveAlphabet(o)) + for (const id of generateIds(o).ids) { + for (const ch of id) expect(allowed.has(ch)).toBe(true) + } + }, + ), + { numRuns: 100 }, + ) + }) + + it('distribution is not visibly biased toward the start of the alphabet', () => { + // A modulo-based implementation skews toward early characters. With 16 + // symbols and 16k draws, every symbol should land near 1/16 of the time. + const { ids } = generateIds( + opts({ alphabet: 'hex', prefix: '', separator: '', length: 256, count: 64 }), + ) + const counts = new Map() + let total = 0 + for (const id of ids) { + for (const ch of id) { + counts.set(ch, (counts.get(ch) ?? 0) + 1) + total++ + } + } + const expected = total / 16 + for (const [, n] of counts) { + expect(Math.abs(n - expected) / expected).toBeLessThan(0.2) + } + expect(counts.size).toBe(16) + }) +}) diff --git a/src/tools/id-gen/core/generate.ts b/src/tools/id-gen/core/generate.ts new file mode 100644 index 0000000..cb63ae4 --- /dev/null +++ b/src/tools/id-gen/core/generate.ts @@ -0,0 +1,219 @@ +import { cryptoRandomSource, unbiasedBelow, type RandomSource } from '@/lib/random' + +/** + * Prefixed identifier generation, in the shape Stripe popularised: + * a readable prefix, a separator, then a run of random characters + * (`sk_live_` followed by 24 alphanumerics). + * + * The example above is written out rather than shown as a literal on purpose: + * a realistic-looking key in source trips secret scanners, which cannot tell a + * documentation example from a real leak -- and should not have to. + * + * A readable prefix carries the environment and the object type, so an id is + * self-describing in a log line, and a leaked key is identifiable on sight. + */ + +export const ALPHABETS = { + alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + lowercase: 'abcdefghijklmnopqrstuvwxyz0123456789', + uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + letters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + numeric: '0123456789', + hex: '0123456789abcdef', + /** Bitcoin base58: alphanumeric minus 0, O, I and l. */ + base58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', + /** Adds symbols, for password use. */ + password: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.?', +} as const + +export type AlphabetName = keyof typeof ALPHABETS + +/** + * Glyphs that are easily confused when read aloud or transcribed by hand. + * Worth excluding for anything a human will retype; pointless for a machine + * token, which is why it is optional. + */ +const AMBIGUOUS = new Set('O0oIl1|`\'"~,.;:') + +export interface IdOptions { + prefix: string + separator: string + /** Length of the random portion only, excluding prefix and separator. */ + length: number + alphabet: AlphabetName | 'custom' + customAlphabet?: string + excludeAmbiguous: boolean + count: number +} + +export const DEFAULT_OPTIONS: IdOptions = { + prefix: 'sk_live', + separator: '_', + length: 24, + alphabet: 'alphanumeric', + excludeAmbiguous: false, + count: 10, +} + +/** Deduplicated so a repeated character cannot skew the distribution. */ +export function resolveAlphabet(options: Pick): string { + const base = + options.alphabet === 'custom' + ? (options.customAlphabet ?? '') + : ALPHABETS[options.alphabet] + + const seen = new Set() + let out = '' + for (const ch of base) { + if (seen.has(ch)) continue + if (options.excludeAmbiguous && AMBIGUOUS.has(ch)) continue + seen.add(ch) + out += ch + } + return out +} + +export type GenerateError = 'empty-alphabet' | 'invalid-length' | 'invalid-count' + +export interface GenerateResult { + ids: string[] + alphabetSize: number + /** Entropy of the random portion. The prefix contributes none -- it is public. */ + entropyBits: number + error?: GenerateError +} + +export const MAX_COUNT = 1000 +export const MAX_LENGTH = 512 + +export function generateIds(options: IdOptions, source?: RandomSource): GenerateResult { + const alphabet = resolveAlphabet(options) + const alphabetSize = alphabet.length + + if (alphabetSize === 0) { + return { ids: [], alphabetSize: 0, entropyBits: 0, error: 'empty-alphabet' } + } + if (!Number.isInteger(options.length) || options.length < 1 || options.length > MAX_LENGTH) { + return { ids: [], alphabetSize, entropyBits: 0, error: 'invalid-length' } + } + if (!Number.isInteger(options.count) || options.count < 1 || options.count > MAX_COUNT) { + return { ids: [], alphabetSize, entropyBits: 0, error: 'invalid-count' } + } + + const next = source ?? cryptoRandomSource() + const chars = [...alphabet] + const head = options.prefix === '' ? '' : options.prefix + options.separator + + const ids: string[] = [] + for (let n = 0; n < options.count; n++) { + let body = '' + for (let i = 0; i < options.length; i++) { + // Rejection sampling, not modulo: `value % size` biases toward the start + // of the alphabet whenever size does not divide 2^32. + body += chars[unbiasedBelow(next, chars.length)] + } + ids.push(head + body) + } + + return { ids, alphabetSize, entropyBits: entropyBits(alphabetSize, options.length) } +} + +export function entropyBits(alphabetSize: number, length: number): number { + if (alphabetSize <= 1 || length <= 0) return 0 + return Math.log2(alphabetSize) * length +} + +export type Strength = 'weak' | 'fair' | 'strong' | 'excessive' + +export interface StrengthVerdict { + level: Strength + label: string + detail: string +} + +/** + * Qualitative strength from entropy. + * + * Deliberately not a "time to crack" figure: that number depends entirely on + * assumed hardware and on whether the value is hashed and how, so quoting one + * would be false precision dressed up as a guarantee. + */ +export function strength(bits: number): StrengthVerdict { + if (bits < 64) { + return { + level: 'weak', + label: 'Weak', + detail: 'Fine for a non-secret identifier, but not for anything that authenticates.', + } + } + if (bits < 80) { + return { + level: 'fair', + label: 'Fair', + detail: 'Acceptable for a short-lived token. Prefer more for a long-lived secret.', + } + } + if (bits <= 256) { + return { + level: 'strong', + label: 'Strong', + detail: 'Comfortably beyond brute force for a credential of this kind.', + } + } + return { + level: 'excessive', + label: 'Very high', + detail: 'Far beyond any practical need. Extra length costs storage and legibility.', + } +} + +export interface Preset { + name: string + description: string + options: Partial +} + +export const PRESETS: readonly Preset[] = [ + { + name: 'Stripe secret key', + description: 'sk_live_ plus 24 alphanumeric characters', + options: { prefix: 'sk_live', separator: '_', length: 24, alphabet: 'alphanumeric' }, + }, + { + name: 'Stripe test key', + description: 'sk_test_ plus 24 alphanumeric characters', + options: { prefix: 'sk_test', separator: '_', length: 24, alphabet: 'alphanumeric' }, + }, + { + name: 'Object id', + description: 'A short prefixed id, e.g. cus_ or evt_', + options: { prefix: 'cus', separator: '_', length: 14, alphabet: 'alphanumeric' }, + }, + { + name: 'API token', + description: '40 alphanumeric characters, no prefix', + options: { prefix: '', separator: '', length: 40, alphabet: 'alphanumeric' }, + }, + { + name: 'Hex token', + description: '32 hex characters, like a session id', + options: { prefix: '', separator: '', length: 32, alphabet: 'hex' }, + }, + { + name: 'Password', + description: '20 characters with symbols, ambiguous glyphs removed', + options: { + prefix: '', + separator: '', + length: 20, + alphabet: 'password', + excludeAmbiguous: true, + count: 5, + }, + }, + { + name: 'Readable code', + description: 'base58 with ambiguous glyphs removed, for codes people retype', + options: { prefix: '', separator: '', length: 10, alphabet: 'base58', excludeAmbiguous: true }, + }, +] diff --git a/src/tools/list/core/pipeline.ts b/src/tools/list/core/pipeline.ts index 750d2d4..76ca538 100644 --- a/src/tools/list/core/pipeline.ts +++ b/src/tools/list/core/pipeline.ts @@ -1,7 +1,7 @@ import { applyCase } from './casing' import { comparatorFor } from './compare' import { isBlank, parseLines, withLines } from './lines' -import { cryptoRandomSource, mulberry32, shuffleInPlace } from './shuffle' +import { cryptoRandomSource, mulberry32, shuffleInPlace } from '@/lib/random' import type { BlankPolicy, LineDoc, LineTest, Op, StepStat } from './types' function partitionBlanks(lines: string[], policy: BlankPolicy) { diff --git a/vitest.config.ts b/vitest.config.ts index 4a46fb8..00ee0a7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ test: { // core/ is pure: no DOM needed, and node is much faster to boot. environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], // Transforms dominate the run time otherwise. fsModuleCache: true, }, From 014de4da3b31b7c55e29d4655b703f2b906b9278 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Thu, 24 Sep 2026 14:37:21 +0700 Subject: [PATCH 2/2] Update actions past the Node 20 deprecation; clear new lint warnings GitHub is forcing actions that target Node 20 onto Node 24 and warning about it, so move to the current majors rather than wait for them to break. Also clears the two warnings the run surfaced in the new release scripts: a Set lookup instead of repeated array scans, and a test helper hoisted out of its describe block. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/release.yml | 6 +++--- scripts/changelog.test.ts | 4 ++-- scripts/changelog.ts | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df2b91..0c54534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,11 @@ jobs: name: Typecheck, lint, test, build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2e9bdd..7acabc6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,14 +28,14 @@ jobs: name: production url: https://devtools.fadeltd.dev steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # Deploy exactly the tagged commit, whichever way this was started. ref: ${{ inputs.tag || github.ref }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm @@ -57,7 +57,7 @@ jobs: run: pnpm build - name: Deploy - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98cfa0c..9ce0032 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,13 +31,13 @@ jobs: # triggers Deploy natively; without one we start Deploy explicitly. HAS_PAT: ${{ secrets.RELEASE_TOKEN != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm diff --git a/scripts/changelog.test.ts b/scripts/changelog.test.ts index edd2e09..6f450ca 100644 --- a/scripts/changelog.test.ts +++ b/scripts/changelog.test.ts @@ -37,9 +37,9 @@ describe('parseUnreleased', () => { }) }) -describe('determineBump', () => { - const bumpOf = (body: string) => determineBump(parseUnreleased(doc(body))) +const bumpOf = (body: string) => determineBump(parseUnreleased(doc(body))) +describe('determineBump', () => { it('is none when there is nothing to release', () => { expect(bumpOf('Nothing yet.')).toBe('none') }) diff --git a/scripts/changelog.ts b/scripts/changelog.ts index de34986..041b912 100644 --- a/scripts/changelog.ts +++ b/scripts/changelog.ts @@ -45,9 +45,9 @@ export function parseUnreleased(changelog: string): Unreleased { */ export function determineBump(u: Unreleased): Bump { if (!u.hasContent) return 'none' - const lower = u.sections.map((s) => s.toLowerCase()) - if (lower.includes('breaking') || lower.includes('breaking changes')) return 'major' - if (lower.includes('added')) return 'minor' + const headings = new Set(u.sections.map((s) => s.toLowerCase())) + if (headings.has('breaking') || headings.has('breaking changes')) return 'major' + if (headings.has('added')) return 'minor' return 'patch' }