diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 337ecaf32..a896d034b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,7 @@ If guidance conflicts, follow the order above. - Shared repo policy/settings (`common-settings.yaml`, `.github/settings.yml`) - Development standards and quality gates (TypeScript/ESLint/Prettier) - Control-plane TypeScript (`scripts/*.ts`, executed with Node 24 native TS; no build step) +- Package release (`.github/workflows/release-wiki-write-core.yaml`, `packages/wiki-write-core/.releaserc.yaml`, `scripts/wiki-write-core-release.ts`): a merge to `main` that changes `packages/wiki-write-core/` publishes `@fro.bot/wiki-write-core` to npm via semantic-release; the release tools install into a scratch prefix, never `package.json` or the lockfile - Metadata state (`metadata/*.yaml`, written programmatically to the `data` branch) - Knowledge wiki (`knowledge/{schema,index,log}.md` + `knowledge/wiki/`, Karpathy-style LLM-generated) - Documented solutions (`docs/solutions/`, categorized learnings and fixes with YAML frontmatter for searchability) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8d72faf3b..55abf2478 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -8,6 +8,23 @@ matchPackageNames: ['!python', '!typescript'], enabled: false, }, + { + // These tools are pinned in release-wiki-write-core.yaml, not in package.json, so the patch-update + // default above would leave them unmaintained. + description: 'Re-enable patch updates for the pinned release tools of @fro.bot/wiki-write-core.', + matchUpdateTypes: ['patch'], + matchPackageNames: [ + '@semantic-release/commit-analyzer', + '@semantic-release/exec', + '@semantic-release/github', + '@semantic-release/npm', + '@semantic-release/release-notes-generator', + 'conventional-changelog-conventionalcommits', + 'conventional-changelog-writer', + 'semantic-release', + ], + enabled: true, + }, { description: 'Do not group TypeScript and Python with other updates', matchPackageNames: ['python', 'typescript'], diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a0740542b..23239be5e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -164,6 +164,20 @@ jobs: RENOVATE_PREFIX: ${{ runner.temp }}/renovate run: pnpm exec vitest run scripts/check-renovate-config.test.ts scripts/check-renovate-ranges.test.ts scripts/renovate-runtime.test.ts + - name: ๐Ÿ“ฆ Install Release Tools + # The semantic-release tool set pinned in release-wiki-write-core.yaml goes into a scratch prefix + # outside the checkout, with lifecycle scripts off. Nothing is added to package.json or pnpm-lock.yaml. + env: + WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release + run: node scripts/wiki-write-core-release.ts install + + - name: ๐Ÿงช Test Release Decision Against Pinned Release Tools + # `pnpm test` skips the tests that need the real plugins; run them here, against the install + # the previous step left in WIKI_WRITE_CORE_RELEASE_PREFIX. They fail if that prefix is empty. + env: + WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release + run: pnpm exec vitest run scripts/wiki-write-core-release.test.ts + check-wiki-authority: name: Check Wiki Authority # Only pull_request events carry a PR author to evaluate against the guard. diff --git a/.github/workflows/release-wiki-write-core.yaml b/.github/workflows/release-wiki-write-core.yaml new file mode 100644 index 000000000..cb3eee32f --- /dev/null +++ b/.github/workflows/release-wiki-write-core.yaml @@ -0,0 +1,272 @@ +# Releases @fro.bot/wiki-write-core to npm on every merge to main that changes the package. +# +# Three jobs, split by what they may hold: +# decide (contents: read) runs repository code: picks the commits that touch the package and computes +# the release type, version and notes. Holds no credential. +# build (contents: read) proves the committed packages/wiki-write-core/dist equals a fresh build. +# publish (contents: write, id-token: write, environment: release) runs NO repository code: it validates +# decide's outputs against fixed patterns, writes a pure-data semantic-release +# config from the template below, and publishes the committed dist from its own +# checkout. +# +# The npm trusted publisher is bound to this workflow's filename. Rename it only together with that binding. +name: Release Wiki Write Core + +on: + push: + branches: [main] + workflow_dispatch: + +# Least-privilege default: no token capabilities at the workflow level. Each job grants only what it needs. +permissions: {} + +# Serialize releases; never cancel one mid-flight. +concurrency: + group: release-wiki-write-core + cancel-in-progress: false + +env: + # The one declaration of the release tools. scripts/wiki-write-core-release.ts reads these from this file + # for the scratch install, and the inherited bfra-me regex manager for `*_VERSION: x # renovate: ...` in + # workflow files keeps them current. They are not repo dependencies: semantic-release's tree carries + # `braces` with no patched version, which `check:override-floors` fails on. `publish` installs the first + # four; `decide` installs all eight. + SEMANTIC_RELEASE_VERSION: 25.0.9 # renovate: datasource=npm depName=semantic-release + SEMANTIC_RELEASE_EXEC_VERSION: 7.1.0 # renovate: datasource=npm depName=@semantic-release/exec + SEMANTIC_RELEASE_NPM_VERSION: 13.2.0 # renovate: datasource=npm depName=@semantic-release/npm + SEMANTIC_RELEASE_GITHUB_VERSION: 12.0.10 # renovate: datasource=npm depName=@semantic-release/github + SEMANTIC_RELEASE_COMMIT_ANALYZER_VERSION: 13.0.1 # renovate: datasource=npm depName=@semantic-release/commit-analyzer + SEMANTIC_RELEASE_NOTES_GENERATOR_VERSION: 14.1.1 # renovate: datasource=npm depName=@semantic-release/release-notes-generator + CONVENTIONAL_CHANGELOG_CONVENTIONALCOMMITS_VERSION: 10.4.1 # renovate: datasource=npm depName=conventional-changelog-conventionalcommits + CONVENTIONAL_CHANGELOG_WRITER_VERSION: 9.3.0 # renovate: datasource=npm depName=conventional-changelog-writer + +defaults: + run: + shell: bash -Eeuo pipefail {0} + +jobs: + # --------------------------------------------------------------------------- + # Decide job: credential-free. Runs repository code, so it holds read scopes only. + # --------------------------------------------------------------------------- + decide: + name: Decide package release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + type: ${{ steps.decision.outputs.type }} + version: ${{ steps.decision.outputs.version }} + base-tag: ${{ steps.decision.outputs.base-tag }} + steps: + - name: โคต Checkout Branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: ๐Ÿ“ฆ Setup + uses: ./.github/actions/setup + + - name: ๐Ÿ“ฆ Install Release Tools + env: + WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release + run: node scripts/wiki-write-core-release.ts install + + - name: ๐Ÿงช Test Release Decision Against Pinned Release Tools + env: + WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release + run: pnpm exec vitest run scripts/wiki-write-core-release.test.ts + + - name: ๐Ÿ”Ž Decide release + id: decision + env: + WIKI_WRITE_CORE_RELEASE_PREFIX: ${{ runner.temp }}/wiki-write-core-release + run: node scripts/wiki-write-core-release.ts decide --notes "$RUNNER_TEMP/release-notes/notes.md" + + - name: ๐Ÿ“ค Upload release notes + if: steps.decision.outputs.type != 'none' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-notes + path: ${{ runner.temp }}/release-notes/notes.md + if-no-files-found: error + retention-days: 7 + + # --------------------------------------------------------------------------- + # Build job: credential-free. Proves the committed dist is what a fresh build produces; nothing it + # builds is published. + # --------------------------------------------------------------------------- + build: + name: Verify package build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: โคต Checkout Branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: ๐Ÿ“ฆ Setup + uses: ./.github/actions/setup + + - name: โœ… Verify committed package build + run: pnpm check:wiki-write-core-dist + + # --------------------------------------------------------------------------- + # Publish job: the only job with write scopes and the release environment. It runs no repository script and + # executes no command taken from another job's output. + # --------------------------------------------------------------------------- + publish: + name: Publish package + needs: [decide, build] + if: >- + github.ref == 'refs/heads/main' && + needs.decide.result == 'success' && + needs.build.result == 'success' && + contains(fromJSON('["major","minor","patch"]'), needs.decide.outputs.type) + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: release + permissions: + contents: write + id-token: write + steps: + - name: โคต Checkout Branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + # No registry-url: it would write an .npmrc that forces token auth and defeat npm trusted publishing + # (OIDC). + - name: ๐Ÿ“ฆ Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: ๐Ÿ”’ Require npm >= 11.5.1 + id: npm-gate + run: | + version=$(npm --version) + oldest=$(printf '%s\n' 11.5.1 "$version" | sort -V | head -n 1) + if [ "$oldest" != "11.5.1" ]; then + echo "::error::npm $version is older than 11.5.1, which trusted publishing requires" + exit 1 + fi + + - name: ๐Ÿ“ฅ Install release tools + id: install + run: | + tools="$RUNNER_TEMP/wiki-write-core-release-tools" + mkdir -p "$tools" + npm install --prefix "$tools" --no-save --no-package-lock --ignore-scripts --no-audit --no-fund \ + "semantic-release@${SEMANTIC_RELEASE_VERSION}" \ + "@semantic-release/exec@${SEMANTIC_RELEASE_EXEC_VERSION}" \ + "@semantic-release/npm@${SEMANTIC_RELEASE_NPM_VERSION}" \ + "@semantic-release/github@${SEMANTIC_RELEASE_GITHUB_VERSION}" + + # Every value below came from `decide`, which runs repository code. None is executed or interpolated into + # a command: each is matched against a fixed pattern, the version is recomputed from the base tag and type, + # and git confirms the package really changed since the base tag. The values are never echoed. + - name: ๐Ÿ›ก Validate release decision + id: validate + env: + DECISION_TYPE: ${{ needs.decide.outputs.type }} + DECISION_VERSION: ${{ needs.decide.outputs.version }} + DECISION_BASE_TAG: ${{ needs.decide.outputs.base-tag }} + run: | + fail() { echo "::error::$1"; exit 1; } + semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + + [[ $DECISION_TYPE =~ ^(major|minor|patch)$ ]] || fail "the decided release type is not major, minor or patch" + [[ $DECISION_VERSION =~ $semver_re ]] || fail "the decided version is not x.y.z" + [[ $DECISION_BASE_TAG == wiki-write-core-v* ]] || fail "the decided base tag is not a wiki-write-core-v tag" + base_version=${DECISION_BASE_TAG#wiki-write-core-v} + [[ $base_version =~ $semver_re ]] || fail "the decided base tag is not a wiki-write-core-v tag" + + IFS=. read -r major minor patch <<< "$base_version" + case "$DECISION_TYPE" in + major) expected="$((major + 1)).0.0" ;; + minor) expected="${major}.$((minor + 1)).0" ;; + patch) expected="${major}.${minor}.$((patch + 1))" ;; + esac + [ "$DECISION_VERSION" = "$expected" ] || fail "the decided version is not the base tag version incremented by the decided type" + if [ "$DECISION_TYPE" = major ] && [ "$major" -eq 0 ]; then + fail "a 0.x package does not release a major version" + fi + + git rev-parse --verify --quiet "refs/tags/${DECISION_BASE_TAG}^{commit}" > /dev/null \ + || fail "the decided base tag is not present in this checkout" + git merge-base --is-ancestor "refs/tags/${DECISION_BASE_TAG}" HEAD \ + || fail "the decided base tag is not an ancestor of the checked-out commit" + count=$(git rev-list --count "refs/tags/${DECISION_BASE_TAG}..HEAD" -- packages/wiki-write-core/) + if [ "$count" -lt 1 ]; then + fail "no commit since the base tag changes packages/wiki-write-core/" + fi + + - name: ๐Ÿ“ฅ Download release notes + id: notes + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-notes + path: ${{ runner.temp }}/release-notes + + # The package's own .releaserc.yaml is deleted so the config below is the only one semantic-release can + # find. It is pure data: the notes file is only ever `cat`, and the version assertion compares strings. + - name: ๐Ÿงพ Stage release config + id: stage + env: + DECISION_TYPE: ${{ needs.decide.outputs.type }} + DECISION_VERSION: ${{ needs.decide.outputs.version }} + run: | + notes_dir="$RUNNER_TEMP/release-notes" + notes="$notes_dir/notes.md" + if [ "$(find "$notes_dir" -mindepth 1 | wc -l)" -ne 1 ] || [ ! -f "$notes" ] || [ -L "$notes" ] || [ ! -s "$notes" ]; then + echo "::error::the release notes artifact must contain exactly one non-empty regular file, notes.md" + exit 1 + fi + + rm -f packages/wiki-write-core/.releaserc.yaml + jq -n \ + --arg type "$DECISION_TYPE" \ + --arg version "$DECISION_VERSION" \ + --arg notes "$notes" \ + --arg repositoryUrl "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" \ + '{ + branches: ["main"], + tagFormat: "wiki-write-core-v${version}", + repositoryUrl: $repositoryUrl, + plugins: [ + ["@semantic-release/exec", { + analyzeCommitsCmd: ("echo " + $type), + verifyReleaseCmd: ("test \"${nextRelease.version}\" = \"" + $version + "\""), + generateNotesCmd: ("cat \"" + $notes + "\"") + }], + ["@semantic-release/npm", {pkgRoot: "packages/wiki-write-core"}], + ["@semantic-release/github", { + releasedLabels: false, + successComment: false, + failComment: false, + failTitle: false + }] + ] + }' > .releaserc.json + + - name: ๐Ÿš€ Release + id: release + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' + npm_config_ignore_scripts: 'true' + run: '"$RUNNER_TEMP/wiki-write-core-release-tools/node_modules/.bin/semantic-release" --ci' diff --git a/README.md b/README.md index 2657cbb0a..5435828bd 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ This repository provides shared configurations and automation for the Fro Bot ec โ”œโ”€โ”€ .github/ # GitHub-specific configurations โ”‚ โ”œโ”€โ”€ actions/setup/ # Composite bootstrap action โ”‚ โ”œโ”€โ”€ hooks/ # Copilot governance hooks -โ”‚ โ”œโ”€โ”€ workflows/ # 31 GitHub Actions workflows (see Automation) +โ”‚ โ”œโ”€โ”€ workflows/ # 32 GitHub Actions workflows (see Automation) โ”‚ โ”œโ”€โ”€ copilot-instructions.md # Canonical AI-assistant guidance โ”‚ โ”œโ”€โ”€ renovate.json5 # Dependency management config โ”‚ โ””โ”€โ”€ settings.yml # Repository settings via Probot @@ -235,6 +235,7 @@ Fro Bot control plane: | **Cross-Repo Dispatch** | Decompose, dispatch, and track cross-repo goal items via worker receipts | Issue labeled/reopened, every 6 hours, dispatch | | **Publish Wiki** | Build the Quartz site from the wiki and deploy it to GitHub Pages | Push to main touching `knowledge/wiki/**`, `knowledge/index.md`, `knowledge/schema.md`, or `quartz-site/**`; dispatch | | **Unpublish Wiki (emergency takedown)** | Replace the live wiki site with a static "unavailable" page | Manual dispatch | +| **Release Wiki Write Core** | Publish `@fro.bot/wiki-write-core` to npm with provenance when a merge changes `packages/wiki-write-core/`: a credential-free job decides the release and a job in the `release` environment publishes the committed `dist` | Push to main; dispatch | Repository management: diff --git a/packages/wiki-write-core/.releaserc.yaml b/packages/wiki-write-core/.releaserc.yaml new file mode 100644 index 000000000..655e84cc1 --- /dev/null +++ b/packages/wiki-write-core/.releaserc.yaml @@ -0,0 +1,56 @@ +# Release policy for @fro.bot/wiki-write-core. Read only by scripts/wiki-write-core-release.ts in the +# credential-free `decide` job; the `publish` job deletes this file and runs semantic-release from a +# gated config written by .github/workflows/release-wiki-write-core.yaml. +branches: + - main + +tagFormat: 'wiki-write-core-v${version}' + +analyzeCommits: + releaseRules: + # The package stays 0.x: a breaking change is a minor bump, never major. The scoped suppression + # rules below still win when a breaking commit also matches one of them (`build(dev)!:` releases + # nothing). + - breaking: true + release: minor + - type: build + release: patch + - type: build + scope: dev + release: false + - type: docs + release: patch + scope: readme + - type: skip + release: false + +preset: conventionalcommits + +presetConfig: + types: + - type: feat + section: Features + - type: features + section: Features + - type: build + section: Build System + - type: fix + section: Bug Fixes + - type: docs + section: Documentation + - type: test + section: Tests + - type: ci + section: Continuous Integration + - type: style + section: Styles + - type: refactor + section: Code Refactoring + - type: perf + section: Performance Improvements + - type: revert + section: Reverts + - type: chore + section: Miscellaneous Chores + - type: skip + effect: hidden diff --git a/scripts/release-wiki-write-core-workflow.test.ts b/scripts/release-wiki-write-core-workflow.test.ts new file mode 100644 index 000000000..3f0726c83 --- /dev/null +++ b/scripts/release-wiki-write-core-workflow.test.ts @@ -0,0 +1,719 @@ +/** + * Contract tests for .github/workflows/release-wiki-write-core.yaml: the decide/build/publish split, + * the credential boundary of each job, the gated semantic-release config the publish job writes, and the + * validation steps that stand between a `decide` output and the credentialed release step. Style mirrors + * publish-wiki-workflow.test.ts; the publish job's shell is run under bash. + */ + +import {execFileSync} from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import process from 'node:process' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {parse} from 'yaml' + +import {parseToolPins, RELEASE_TOOLS} from './wiki-write-core-release.ts' + +interface WorkflowStep { + name?: string + id?: string + if?: string + run?: string + uses?: string + env?: Record + with?: Record +} + +interface WorkflowJob { + name?: string + if?: string + needs?: string | string[] + permissions?: Record + environment?: string | {name?: string} + steps: WorkflowStep[] +} + +interface Workflow { + on: Record + permissions?: Record + concurrency?: {group?: string; 'cancel-in-progress'?: boolean} + env?: Record + jobs: Record +} + +function assertWorkflowShape(value: unknown): asserts value is Workflow { + if ( + typeof value !== 'object' || + value === null || + !('jobs' in value) || + typeof (value as Record).jobs !== 'object' || + Object.keys((value as {jobs: object}).jobs).length === 0 + ) { + throw new TypeError('workflow file does not have expected shape: missing or empty jobs object') + } +} + +/** A GitHub Actions expression, `${{ body }}`, built so the source holds no template-looking string literal. */ +function expression(body: string): string { + return ['$', '{{ ', body, ' }}'].join('') +} + +/** The semantic-release template variable the exec plugin resolves before running a command. */ +const NEXT_RELEASE_VERSION = ['$', '{nextRelease.version}'].join('') + +const repositoryRoot = resolve(import.meta.dirname, '..') +// The npm trusted publisher binds to this filename. +const workflowPath = join(repositoryRoot, '.github/workflows/release-wiki-write-core.yaml') +const workflowRaw = readFileSync(workflowPath, 'utf8') +const parsed: unknown = parse(workflowRaw) +assertWorkflowShape(parsed) +const workflow = parsed + +const decideJob = workflow.jobs.decide +const buildJob = workflow.jobs.build +const publishJob = workflow.jobs.publish + +function stepById(job: WorkflowJob | undefined, id: string): WorkflowStep { + const step = job?.steps.find(candidate => candidate.id === id) + if (step === undefined) throw new Error(`step with id "${id}" not found`) + return step +} + +function stepIndexById(job: WorkflowJob | undefined, id: string): number { + const index = job?.steps.findIndex(candidate => candidate.id === id) ?? -1 + if (index < 0) throw new Error(`step with id "${id}" not found`) + return index +} + +function runOf(step: WorkflowStep): string { + if (typeof step.run !== 'string') throw new TypeError(`step "${step.name ?? step.id ?? '?'}" has no run script`) + return step.run +} + +function environmentName(job: WorkflowJob | undefined): string | undefined { + const environment = job?.environment + return typeof environment === 'string' ? environment : environment?.name +} + +function readRaw(relativePath: string): string { + return readFileSync(join(repositoryRoot, relativePath), 'utf8') +} + +describe('release-wiki-write-core.yaml structure', () => { + it('has the decide, build and publish jobs (fails when the file has no jobs)', () => { + expect(Object.keys(workflow.jobs)).toEqual(['decide', 'build', 'publish']) + }) + + it('has empty workflow-level permissions', () => { + expect(workflow.permissions).toEqual({}) + }) + + it('triggers on push to main with no paths filter, and on dispatch', () => { + const push = (workflow.on as {push?: {branches?: string[]; paths?: unknown; 'paths-ignore'?: unknown}}).push + expect(push?.branches).toEqual(['main']) + expect(push).not.toHaveProperty('paths') + expect(push).not.toHaveProperty('paths-ignore') + expect(workflow.on).toHaveProperty('workflow_dispatch') + }) + + it('serializes releases and never cancels one in flight', () => { + expect(workflow.concurrency?.group).toBe('release-wiki-write-core') + expect(workflow.concurrency?.['cancel-in-progress']).toBe(false) + }) +}) + +describe('release-wiki-write-core.yaml permissions and environment', () => { + it('gives decide and build contents: read and nothing else', () => { + expect(decideJob?.permissions).toEqual({contents: 'read'}) + expect(buildJob?.permissions).toEqual({contents: 'read'}) + }) + + it('gives only publish contents: write and id-token: write', () => { + expect(publishJob?.permissions).toEqual({contents: 'write', 'id-token': 'write'}) + }) + + it('references the release environment from publish alone', () => { + expect(environmentName(publishJob)).toBe('release') + expect(environmentName(decideJob)).toBeUndefined() + expect(environmentName(buildJob)).toBeUndefined() + }) + + it('mints no App token anywhere in the workflow', () => { + expect(workflowRaw).not.toContain('create-github-app-token') + expect(workflowRaw).not.toMatch(/APPLICATION_PRIVATE_KEY|RELEASE_APP/u) + }) + + it('does not collide with a required status check context in settings.yml', () => { + const settings = parse(readRaw('.github/settings.yml')) as { + branches: {name?: string; protection?: {required_status_checks?: {contexts?: unknown[]}}}[] + } + const contexts = settings.branches.find(branch => branch.name === 'main')?.protection?.required_status_checks + ?.contexts + expect(Array.isArray(contexts) && contexts.length > 0).toBe(true) + for (const job of Object.values(workflow.jobs)) { + expect(contexts).not.toContain(job.name) + } + }) +}) + +describe('release-wiki-write-core.yaml gating', () => { + it('has publish need decide and build', () => { + expect(publishJob?.needs).toEqual(['decide', 'build']) + }) + + it('requires both jobs to succeed and a releasable decided type before publish runs', () => { + const condition = String(publishJob?.if ?? '').replaceAll(/\s+/gu, ' ') + expect(condition).toContain("needs.decide.result == 'success'") + expect(condition).toContain("needs.build.result == 'success'") + expect(condition).toContain('needs.decide.outputs.type') + expect(condition).toContain('["major","minor","patch"]') + expect(condition).toContain("github.ref == 'refs/heads/main'") + expect(condition).not.toMatch(/always\(\)|failure\(\)|!\s*cancelled\(\)/u) + }) + + it('has no escape hatch on publish steps', () => { + for (const step of publishJob?.steps ?? []) { + expect(step).not.toHaveProperty('continue-on-error') + expect(String(step.if ?? '')).not.toMatch(/always\(\)|failure\(\)|!\s*cancelled\(\)/u) + } + }) + + it('exposes the decided type, version and base tag as decide outputs', () => { + const outputs = (decideJob as WorkflowJob & {outputs?: Record}).outputs + expect(outputs).toEqual({ + type: expression('steps.decision.outputs.type'), + version: expression('steps.decision.outputs.version'), + 'base-tag': expression('steps.decision.outputs.base-tag'), + }) + }) + + it('runs the real-plugin integration tests against the scratch install before deciding', () => { + const installIndex = stepIndexById(decideJob, 'decision') - 2 + const steps = decideJob?.steps ?? [] + expect(runOf(steps[installIndex] ?? {})).toBe('node scripts/wiki-write-core-release.ts install') + expect(runOf(steps[installIndex + 1] ?? {})).toBe('pnpm exec vitest run scripts/wiki-write-core-release.test.ts') + const prefixes = steps.slice(installIndex, installIndex + 3).map(step => step.env?.WIKI_WRITE_CORE_RELEASE_PREFIX) + expect(new Set(prefixes)).toEqual(new Set([`${expression('runner.temp')}/wiki-write-core-release`])) + }) + + it('uploads the notes only when a release was decided', () => { + const upload = (decideJob?.steps ?? []).find(step => step.uses?.startsWith('actions/upload-artifact@') === true) + expect(upload?.if).toBe("steps.decision.outputs.type != 'none'") + expect(upload?.with?.name).toBe('release-notes') + expect(upload?.with?.['if-no-files-found']).toBe('error') + }) + + it('verifies the committed dist in build', () => { + expect((buildJob?.steps ?? []).map(step => step.run)).toContain('pnpm check:wiki-write-core-dist') + }) +}) + +describe('release-wiki-write-core.yaml publish job contents', () => { + const steps = publishJob?.steps ?? [] + + it('uses no local action and runs no repository script', () => { + for (const step of steps) { + expect(String(step.uses ?? '')).not.toMatch(/^\.\//u) + const script = step.run ?? '' + expect(script).not.toMatch(/\bpnpm\b|\bnpx\b|\bnode\s|scripts\//u) + } + }) + + it('interpolates no expression into a script: decision values reach shell only as environment variables', () => { + for (const step of steps) { + expect(step.run ?? '').not.toContain('${{') + } + }) + + it('installs exactly the four publish tools, at their pins, with lifecycle scripts disabled', () => { + const script = runOf(stepById(publishJob, 'install')) + const installed = [...script.matchAll(/"(@?[^"@\s]+)@\$\{([A-Z_]+)\}"/gu)].map(match => [match[1], match[2]]) + + expect(installed).toEqual([ + ['semantic-release', 'SEMANTIC_RELEASE_VERSION'], + ['@semantic-release/exec', 'SEMANTIC_RELEASE_EXEC_VERSION'], + ['@semantic-release/npm', 'SEMANTIC_RELEASE_NPM_VERSION'], + ['@semantic-release/github', 'SEMANTIC_RELEASE_GITHUB_VERSION'], + ]) + expect(script).toContain('--ignore-scripts') + expect(script).toContain('--no-package-lock') + expect(script).not.toMatch(/commit-analyzer|release-notes-generator|conventional-changelog/u) + }) + + it('sets up Node without a registry-url', () => { + const setupNode = steps.find(step => step.uses?.startsWith('actions/setup-node@') === true) + expect(setupNode).toBeDefined() + expect(setupNode?.with).not.toHaveProperty('registry-url') + expect(setupNode?.with?.['node-version']).toBe(24) + }) + + it('checks npm >= 11.5.1, validates the decision, stages the config, then releases, in that order', () => { + const order = ['npm-gate', 'install', 'validate', 'notes', 'stage', 'release'].map(id => + stepIndexById(publishJob, id), + ) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(runOf(stepById(publishJob, 'npm-gate'))).toContain('11.5.1') + expect(runOf(stepById(publishJob, 'validate'))).toContain('git merge-base --is-ancestor') + expect(runOf(stepById(publishJob, 'validate'))).toContain('git rev-list --count') + }) + + it('runs the release tool from the scratch prefix, not from the checkout', () => { + const release = stepById(publishJob, 'release') + expect(runOf(release)).toContain('$RUNNER_TEMP/wiki-write-core-release-tools/node_modules/.bin/semantic-release') + expect(runOf(release)).toContain('--ci') + }) + + it('puts the publish credentials and script policy on the semantic-release step only', () => { + const guarded = ['NPM_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'npm_config_ignore_scripts', 'NPM_CONFIG_PROVENANCE'] + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.id === 'release') continue + for (const key of guarded) { + expect(step.env ?? {}, `${key} on step "${step.name ?? ''}"`).not.toHaveProperty(key) + } + } + } + expect(stepById(publishJob, 'release').env).toEqual({ + GITHUB_TOKEN: expression('github.token'), + GH_TOKEN: expression('github.token'), + NPM_TOKEN: expression('secrets.NPM_TOKEN'), + NPM_CONFIG_PROVENANCE: 'true', + npm_config_ignore_scripts: 'true', + }) + }) + + it('holds no secret anywhere but the release step', () => { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.id === 'release') continue + expect(JSON.stringify(step)).not.toContain('secrets.') + } + } + }) +}) + +describe('release-wiki-write-core.yaml pins and checkouts', () => { + it('pins every remote action to a 40-hex-char commit SHA', () => { + const uses = [...workflowRaw.matchAll(/uses:\s*(\S+)/gu)].map(match => match[1] ?? '') + expect(uses.length).toBeGreaterThan(0) + for (const value of uses) { + if (value.startsWith('./')) continue + expect(value).toMatch(/@[0-9a-f]{40}$/u) + } + }) + + it('pins the actions to SHAs the repository already uses elsewhere', () => { + const others = readdirSync(join(repositoryRoot, '.github/workflows')) + .filter(file => file !== 'release-wiki-write-core.yaml') + .map(file => readRaw(`.github/workflows/${file}`)) + .join('\n') + for (const [, action] of workflowRaw.matchAll(/uses:\s*(\S+@[0-9a-f]{40})/gu)) { + expect(others, `${action ?? ''} is not pinned elsewhere in this repository`).toContain(action ?? '') + } + }) + + it('disables credential persistence on every checkout', () => { + const checkouts = Object.values(workflow.jobs) + .flatMap(job => job.steps) + .filter(step => step.uses?.startsWith('actions/checkout@') === true) + expect(checkouts).toHaveLength(3) + for (const checkout of checkouts) { + expect(checkout.with?.['persist-credentials']).toBe(false) + expect(checkout.with?.ref).toBe(expression('github.sha')) + } + }) + + it('fetches full history and tags in decide and publish', () => { + for (const job of [decideJob, publishJob]) { + const checkout = job?.steps.find(step => step.uses?.startsWith('actions/checkout@') === true) + expect(checkout?.with?.['fetch-depth']).toBe(0) + } + }) +}) + +describe('release-wiki-write-core.yaml release tool pins', () => { + it('declares every tool as an exact workflow-level pin', () => { + const pins = parseToolPins(workflowRaw, 'release-wiki-write-core.yaml') + + expect(Object.keys(pins).toSorted()).toEqual(RELEASE_TOOLS.map(({name}) => name).toSorted()) + expect(pins).toMatchObject({ + 'semantic-release': '25.0.9', + '@semantic-release/exec': '7.1.0', + '@semantic-release/npm': '13.2.0', + '@semantic-release/github': '12.0.10', + '@semantic-release/commit-analyzer': '13.0.1', + '@semantic-release/release-notes-generator': '14.1.1', + 'conventional-changelog-conventionalcommits': '10.4.1', + 'conventional-changelog-writer': '9.3.0', + }) + }) + + it('gives every pin a renovate comment naming the tool it tracks', () => { + for (const {name, versionKey} of RELEASE_TOOLS) { + const line = workflowRaw.split('\n').find(candidate => candidate.trimStart().startsWith(`${versionKey}:`)) + expect(line, `${versionKey} line`).toBeDefined() + expect(line).toMatch( + new RegExp(` # renovate: datasource=npm depName=${name.replaceAll('/', String.raw`\/`)}$`, 'u'), + ) + } + }) + + it('declares the pins once: main.yaml reads them from this workflow', () => { + const main = parse(readRaw('.github/workflows/main.yaml')) as {env?: Record} + for (const {versionKey} of RELEASE_TOOLS) { + expect(main.env ?? {}).not.toHaveProperty(versionKey) + } + }) + + it('re-enables patch updates for exactly these tools in .github/renovate.json5, after the rule that disables them', () => { + // JSON5 without the comment lines is a YAML flow mapping, which `yaml` parses. + const text = readRaw('.github/renovate.json5') + .split('\n') + .filter(line => !line.trimStart().startsWith('//')) + .join('\n') + const config = parse(text) as { + packageRules: {matchUpdateTypes?: string[]; matchPackageNames?: string[]; enabled?: boolean}[] + } + const disableIndex = config.packageRules.findIndex( + rule => rule.matchUpdateTypes?.includes('patch') === true && rule.enabled === false, + ) + const enableIndex = config.packageRules.findIndex( + rule => rule.matchUpdateTypes?.includes('patch') === true && rule.enabled === true, + ) + + expect(disableIndex).toBeGreaterThanOrEqual(0) + expect(enableIndex).toBeGreaterThan(disableIndex) + expect(config.packageRules[enableIndex]?.matchPackageNames?.toSorted()).toEqual( + RELEASE_TOOLS.map(({name}) => name).toSorted(), + ) + }) + + it('leaves no other semantic-release config at the repository root for the gated config to compete with', () => { + const rootFiles = readdirSync(repositoryRoot) + expect(rootFiles.filter(file => /^\.releaserc|^release\.config\./u.test(file))).toEqual([]) + const rootManifest = JSON.parse(readRaw('package.json')) as Record + expect(rootManifest).not.toHaveProperty('release') + }) +}) + +// -- running the publish job's shell ------------------------------------------------------------------- + +interface ShellResult { + status: number + stdout: string + stderr: string +} + +function runShell(script: string, cwd: string, env: Record): ShellResult { + const scriptPath = join(cwd, '.step.sh') + writeFileSync(scriptPath, script) + try { + const stdout = execFileSync('bash', ['-Eeuo', 'pipefail', scriptPath], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: {PATH: process.env.PATH ?? '', HOME: cwd, ...env}, + }) + return {status: 0, stdout, stderr: ''} + } catch (error) { + const failure = error as {status?: number; stdout?: string; stderr?: string} + return {status: failure.status ?? 1, stdout: String(failure.stdout ?? ''), stderr: String(failure.stderr ?? '')} + } finally { + rmSync(scriptPath, {force: true}) + } +} + +function gitIn(dir: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd: dir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + PATH: process.env.PATH ?? '', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }) +} + +describe('publish: validate release decision (run under bash against a real repository)', () => { + const script = runOf(stepById(publishJob, 'validate')) + let dir: string + let counter = 0 + + function commit(message: string, file: string): void { + counter += 1 + mkdirSync(dirname(join(dir, file)), {recursive: true}) + writeFileSync(join(dir, file), `change ${counter}\n`) + gitIn(dir, 'add', '--all') + gitIn(dir, 'commit', '--quiet', '--message', message) + } + + function validate(decision: {type: string; version: string; baseTag: string}): ShellResult { + return runShell(script, dir, { + DECISION_TYPE: decision.type, + DECISION_VERSION: decision.version, + DECISION_BASE_TAG: decision.baseTag, + }) + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'release-publish-validate-')) + counter = 0 + gitIn(dir, 'init', '--quiet', '--initial-branch', 'main') + commit('chore: seed', 'README.md') + gitIn(dir, 'tag', 'wiki-write-core-v0.1.0') + commit('feat: change the package', 'packages/wiki-write-core/src/x.ts') + }) + + afterEach(() => { + rmSync(dir, {recursive: true, force: true}) + }) + + it('accepts a consistent decision that has a package commit since the base tag', () => { + expect(validate({type: 'minor', version: '0.2.0', baseTag: 'wiki-write-core-v0.1.0'})).toMatchObject({status: 0}) + expect(validate({type: 'patch', version: '0.1.1', baseTag: 'wiki-write-core-v0.1.0'})).toMatchObject({status: 0}) + }) + + it('rejects a major release from a 0.x base, even when the version is the recomputed 1.0.0', () => { + const result = validate({type: 'major', version: '1.0.0', baseTag: 'wiki-write-core-v0.1.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::a 0.x package does not release a major version') + }) + + it('accepts a major release from a base of 1.x or higher', () => { + gitIn(dir, 'tag', 'wiki-write-core-v1.2.0') + commit('feat!: break the package', 'packages/wiki-write-core/src/y.ts') + + expect(validate({type: 'major', version: '2.0.0', baseTag: 'wiki-write-core-v1.2.0'})).toMatchObject({status: 0}) + }) + + it.each([ + ['an unknown type', 'rc', '0.2.0'], + ['an empty type', '', '0.2.0'], + ['a type that is a prefix match', 'minor ', '0.2.0'], + ['a type that smuggles a command', '$(touch pwned)', '0.2.0'], + ['a multi-line type', 'minor\nfoo', '0.2.0'], + ])('rejects %s before semantic-release', (_label, type, version) => { + const result = validate({type, version, baseTag: 'wiki-write-core-v0.1.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided release type') + expect(existsSync(join(dir, 'pwned'))).toBe(false) + }) + + it.each([ + ['a two-part version', '0.2'], + ['a prerelease version', '0.2.0-rc.1'], + ['a leading-zero version', '0.02.0'], + ['a version with a suffix', '0.2.0 && echo'], + ['an empty version', ''], + ])('rejects %s', (_label, version) => { + const result = validate({type: 'minor', version, baseTag: 'wiki-write-core-v0.1.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided version is not x.y.z') + }) + + it.each([ + ['an unprefixed tag', 'v0.1.0'], + ['another package tag', 'other-v0.1.0'], + ['a prerelease tag', 'wiki-write-core-v0.1.0-rc.1'], + ['a tag with a ref suffix', 'wiki-write-core-v0.1.0^{commit}'], + ['an empty tag', ''], + ])('rejects %s', (_label, baseTag) => { + const result = validate({type: 'minor', version: '0.2.0', baseTag}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided base tag is not a wiki-write-core-v tag') + }) + + it.each([ + ['a minor version that is the patch increment', 'minor', '0.1.1'], + ['a patch version that is the minor increment', 'patch', '0.2.0'], + ['a major version that stays on 0.x', 'major', '0.2.0'], + ['a version below the base', 'minor', '0.0.1'], + ['a skipped version', 'minor', '0.3.0'], + ])('rejects a received version that differs from the recomputed one: %s', (_label, type, version) => { + const result = validate({type, version, baseTag: 'wiki-write-core-v0.1.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided version is not the base tag version incremented') + }) + + it('rejects a releasable type when no commit since the base tag touches the package', () => { + gitIn(dir, 'tag', 'wiki-write-core-v0.2.0') + commit('feat: scripts only', 'scripts/a.ts') + + const result = validate({type: 'minor', version: '0.3.0', baseTag: 'wiki-write-core-v0.2.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::no commit since the base tag changes packages/wiki-write-core/') + }) + + it('rejects a sibling directory that shares the package name as a prefix', () => { + gitIn(dir, 'tag', 'wiki-write-core-v0.2.0') + commit('feat: sibling', 'packages/wiki-write-core-extra/src/x.ts') + + const result = validate({type: 'minor', version: '0.3.0', baseTag: 'wiki-write-core-v0.2.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::no commit since the base tag changes') + }) + + it('rejects a well-formed base tag that is not in the checkout', () => { + const result = validate({type: 'minor', version: '0.6.0', baseTag: 'wiki-write-core-v0.5.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided base tag is not present in this checkout') + }) + + it('rejects a base tag that is not an ancestor of the checked-out commit', () => { + gitIn(dir, 'switch', '--quiet', '--create', 'side') + commit('feat: on a side branch', 'packages/wiki-write-core/src/side.ts') + gitIn(dir, 'tag', 'wiki-write-core-v0.5.0') + gitIn(dir, 'switch', '--quiet', 'main') + + const result = validate({type: 'minor', version: '0.6.0', baseTag: 'wiki-write-core-v0.5.0'}) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::the decided base tag is not an ancestor') + }) + + it('never echoes a decided value', () => { + const result = validate({type: 'UNIQUE-TYPE-MARKER', version: '0.2.0', baseTag: 'wiki-write-core-v0.1.0'}) + + expect(result.stdout + result.stderr).not.toContain('UNIQUE-TYPE-MARKER') + }) +}) + +describe('publish: stage release config (run under bash)', () => { + const script = runOf(stepById(publishJob, 'stage')) + const releaserc = parse(readRaw('packages/wiki-write-core/.releaserc.yaml')) as { + branches: string[] + tagFormat: string + } + let dir: string + let runnerTemp: string + + function stage(overrides: Record = {}): ShellResult { + return runShell(script, dir, { + RUNNER_TEMP: runnerTemp, + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'fro-bot/.github', + DECISION_TYPE: 'minor', + DECISION_VERSION: '0.2.0', + ...overrides, + }) + } + + function stagedConfig(): { + branches: string[] + tagFormat: string + repositoryUrl: string + plugins: [string, Record][] + } { + return JSON.parse(readFileSync(join(dir, '.releaserc.json'), 'utf8')) as ReturnType + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'release-publish-stage-')) + runnerTemp = join(dir, 'runner-temp') + mkdirSync(join(runnerTemp, 'release-notes'), {recursive: true}) + writeFileSync(join(runnerTemp, 'release-notes', 'notes.md'), '## 0.2.0\n\n* a note\n') + mkdirSync(join(dir, 'packages/wiki-write-core'), {recursive: true}) + writeFileSync(join(dir, 'packages/wiki-write-core/.releaserc.yaml'), 'branches: [main]\n') + }) + + afterEach(() => { + rmSync(dir, {recursive: true, force: true}) + }) + + it('deletes the package config and writes a root config as the only one', () => { + expect(stage()).toMatchObject({status: 0}) + + expect(existsSync(join(dir, 'packages/wiki-write-core/.releaserc.yaml'))).toBe(false) + expect(readdirSync(dir).filter(file => file.startsWith('.releaserc'))).toEqual(['.releaserc.json']) + }) + + it('lists exec, npm and github, in that order, and no commit analyzer or notes generator', () => { + stage() + + const config = stagedConfig() + expect(config.plugins.map(([name]) => name)).toEqual([ + '@semantic-release/exec', + '@semantic-release/npm', + '@semantic-release/github', + ]) + expect(JSON.stringify(config)).not.toMatch(/commit-analyzer|release-notes-generator/u) + }) + + it('takes branches and tagFormat from the package release policy', () => { + stage() + + const config = stagedConfig() + expect(config.branches).toEqual(releaserc.branches) + expect(config.tagFormat).toBe(releaserc.tagFormat) + expect(config.repositoryUrl).toBe('https://github.com/fro-bot/.github.git') + }) + + it('publishes from the package directory and keeps the GitHub plugin from commenting or labeling', () => { + stage() + + const [, , github] = stagedConfig().plugins + const npm = stagedConfig().plugins[1] + expect(npm?.[1]).toEqual({pkgRoot: 'packages/wiki-write-core'}) + expect(github?.[1]).toEqual({releasedLabels: false, successComment: false, failComment: false, failTitle: false}) + }) + + it('gates the release on the approved type, version and notes file', () => { + stage({DECISION_TYPE: 'patch', DECISION_VERSION: '0.1.1'}) + + const exec = stagedConfig().plugins[0]?.[1] + expect(exec).toEqual({ + analyzeCommitsCmd: 'echo patch', + verifyReleaseCmd: `test "${NEXT_RELEASE_VERSION}" = "0.1.1"`, + generateNotesCmd: `cat "${join(runnerTemp, 'release-notes', 'notes.md')}"`, + }) + }) + + it.each([ + ['is missing', (notesDir: string) => rmSync(join(notesDir, 'notes.md'))], + ['is empty', (notesDir: string) => writeFileSync(join(notesDir, 'notes.md'), '')], + [ + 'is a symlink', + (notesDir: string) => { + rmSync(join(notesDir, 'notes.md')) + writeFileSync(join(notesDir, '..', 'target.md'), 'secret') + symlinkSync(join(notesDir, '..', 'target.md'), join(notesDir, 'notes.md')) + }, + ], + ['sits beside another file', (notesDir: string) => writeFileSync(join(notesDir, 'extra.md'), 'x')], + ])('rejects a notes artifact that %s, without writing a config', (_label, tamper) => { + tamper(join(runnerTemp, 'release-notes')) + + const result = stage() + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain( + '::error::the release notes artifact must contain exactly one non-empty regular file', + ) + expect(existsSync(join(dir, '.releaserc.json'))).toBe(false) + expect(existsSync(join(dir, 'packages/wiki-write-core/.releaserc.yaml'))).toBe(true) + }) +}) diff --git a/scripts/wiki-write-core-release.test.ts b/scripts/wiki-write-core-release.test.ts new file mode 100644 index 000000000..fbf8496df --- /dev/null +++ b/scripts/wiki-write-core-release.test.ts @@ -0,0 +1,984 @@ +import type {Decision, Git, InstallCommand, PluginStep, ReleaseCommit, ToolPins} from './wiki-write-core-release.ts' + +import {execFileSync, spawnSync} from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import process from 'node:process' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +import { + assertToolsInstalled, + buildToolManifest, + decideRelease, + filterPackageCommits, + formatGithubOutput, + incrementVersion, + installReleaseTools, + loadReleasePlugins, + parseReleaserc, + parseToolPins, + PREFIX_ENV, + readCommitsSince, + RELEASE_TOOLS, + RELEASERC_FILE, + resolvePrefix, + selectBaseTag, + TAG_FORMAT, + TOOLS_WORKFLOW_FILE, +} from './wiki-write-core-release.ts' + +const repositoryRoot = join(import.meta.dirname, '..') +const PACKAGE_FILE = 'packages/wiki-write-core/src/x.ts' + +function commit(message: string, ...paths: string[]): ReleaseCommit { + return {hash: 'a'.repeat(40), message, committerDate: '2026-10-18T00:00:00Z', paths} +} + +describe('selectBaseTag', () => { + it('picks the highest version by numeric comparison, whatever order the tags were created in', () => { + expect(selectBaseTag(['wiki-write-core-v0.2.0', 'wiki-write-core-v0.10.0'])).toBe('wiki-write-core-v0.10.0') + expect(selectBaseTag(['wiki-write-core-v0.10.0', 'wiki-write-core-v0.2.0'])).toBe('wiki-write-core-v0.10.0') + expect(selectBaseTag(['wiki-write-core-v0.9.9', 'wiki-write-core-v1.0.0', 'wiki-write-core-v0.99.0'])).toBe( + 'wiki-write-core-v1.0.0', + ) + }) + + it('compares patch numbers numerically', () => { + expect(selectBaseTag(['wiki-write-core-v0.1.9', 'wiki-write-core-v0.1.10'])).toBe('wiki-write-core-v0.1.10') + }) + + it('returns undefined when no tag is a wiki-write-core release tag', () => { + expect(selectBaseTag([])).toBeUndefined() + expect(selectBaseTag(['v1.0.0', 'wiki-write-core-v1.0', 'other-v1.0.0'])).toBeUndefined() + }) + + it.each([ + ['a prerelease', 'wiki-write-core-v9.0.0-rc.1'], + ['build metadata', 'wiki-write-core-v9.0.0+build'], + ['a leading zero', 'wiki-write-core-v09.0.0'], + ['a trailing suffix', 'wiki-write-core-v9.0.0x'], + ['a prefix', 'xwiki-write-core-v9.0.0'], + ])('ignores %s', (_label, tag) => { + expect(selectBaseTag([tag, 'wiki-write-core-v0.1.0'])).toBe('wiki-write-core-v0.1.0') + }) +}) + +describe('incrementVersion', () => { + it.each([ + ['0.0.0', 'minor', '0.1.0'], + ['0.1.0', 'minor', '0.2.0'], + ['0.1.7', 'minor', '0.2.0'], + ['0.1.7', 'patch', '0.1.8'], + ['0.10.9', 'patch', '0.10.10'], + ['1.0.0', 'major', '2.0.0'], + ['1.4.2', 'major', '2.0.0'], + ['1.4.2', 'minor', '1.5.0'], + ] as const)('%s + %s = %s', (base, type, expected) => { + expect(incrementVersion(base, type)).toBe(expected) + }) + + it.each(['0.0.0', '0.1.0', '0.9.9'])('refuses a major release from the 0.x base %s', base => { + expect(() => incrementVersion(base, 'major')).toThrow('a 0.x package does not release a major version') + }) +}) + +describe('filterPackageCommits', () => { + it('keeps a commit that changes a path under the package', () => { + const kept = commit('feat: x', 'packages/wiki-write-core/src/x.ts') + expect(filterPackageCommits([kept])).toEqual([kept]) + }) + + it('keeps a commit that touches the package and other trees', () => { + const kept = commit('fix: x', 'scripts/x.ts', 'packages/wiki-write-core/README.md') + expect(filterPackageCommits([kept])).toEqual([kept]) + }) + + it('drops a commit that only touches other trees', () => { + expect(filterPackageCommits([commit('feat: x', 'scripts/x.ts', 'README.md')])).toEqual([]) + }) + + it('does not count a sibling directory that shares the package name as a prefix', () => { + expect(filterPackageCommits([commit('feat: x', 'packages/wiki-write-core-extra/src/x.ts')])).toEqual([]) + }) + + it('does not count a file at the package path itself or a path that merely contains it', () => { + expect( + filterPackageCommits([ + commit('feat: x', 'packages/wiki-write-core'), + commit('feat: y', 'docs/packages/wiki-write-core/x.md'), + ]), + ).toEqual([]) + }) + + it('keeps only the commits that touch the package out of several', () => { + const touching = commit('feat: kept', 'packages/wiki-write-core/src/x.ts') + const result = filterPackageCommits([ + commit('feat: outside one', 'scripts/a.ts'), + touching, + commit('fix: outside two', '.github/workflows/x.yaml'), + ]) + expect(result).toEqual([touching]) + }) + + it('drops commits marked [skip release] or [release skip], in any case, anywhere in the message', () => { + const path = 'packages/wiki-write-core/src/x.ts' + expect(filterPackageCommits([commit('feat: x [skip release]', path)])).toEqual([]) + expect(filterPackageCommits([commit('feat: x [release skip]', path)])).toEqual([]) + expect(filterPackageCommits([commit('feat: x\n\nbody\n\n[Skip Release]', path)])).toEqual([]) + }) + + it('does not treat similar bracket text as a skip marker', () => { + const kept = commit('feat: x [skip ci]', 'packages/wiki-write-core/src/x.ts') + expect(filterPackageCommits([kept])).toEqual([kept]) + }) +}) + +describe('readCommitsSince', () => { + const first = 'a'.repeat(40) + const second = 'b'.repeat(40) + + function fakeGit(overrides: {diff?: (hash: string) => string} = {}): {git: Git; calls: string[][]} { + const calls: string[][] = [] + const git: Git = args => { + calls.push([...args]) + if (args[0] === 'rev-list') return `${first}\n${second}\n` + if (args[0] === 'log') return `2026-10-18T10:00:00+00:00\nfeat: ${args[3]?.[0] ?? ''}\n\nbody line\n` + if (args[0] === 'diff') + return (overrides.diff ?? (() => 'scripts/a.ts\0packages/wiki-write-core/src/x.ts\0'))(String(args[5])) + throw new Error(`unexpected git ${args.join(' ')}`) + } + return {git, calls} + } + + it('lists the commits in the range with their message, date and changed paths', () => { + const {git, calls} = fakeGit() + + const commits = readCommitsSince(git, 'wiki-write-core-v0.0.0', 'HEAD') + + expect(commits.map(commit => commit.hash)).toEqual([first, second]) + expect(commits[0]).toEqual({ + hash: first, + message: 'feat: a\n\nbody line', + committerDate: '2026-10-18T10:00:00+00:00', + paths: ['scripts/a.ts', 'packages/wiki-write-core/src/x.ts'], + }) + expect(calls[0]).toEqual(['rev-list', 'wiki-write-core-v0.0.0..HEAD', '--']) + }) + + it('diffs against the first parent without rename detection, so a path moved out of the package still counts', () => { + const {git, calls} = fakeGit() + + readCommitsSince(git, 'wiki-write-core-v0.0.0', 'HEAD') + + expect(calls.find(call => call[0] === 'diff')).toEqual([ + 'diff', + '--name-only', + '--no-renames', + '-z', + `${first}^1`, + first, + '--', + ]) + }) + + it('fails when the changed paths of a commit cannot be read', () => { + const {git} = fakeGit({ + diff: () => { + throw new Error('fatal: bad revision') + }, + }) + + expect(() => readCommitsSince(git, 'wiki-write-core-v0.0.0', 'HEAD')).toThrow( + new RegExp(`cannot read the changed paths of commit ${first}.*bad revision`), + ) + }) + + it('fails on a rev-list line that is not a commit id', () => { + const git: Git = () => '--upload-pack=evil\n' + + expect(() => readCommitsSince(git, 'wiki-write-core-v0.0.0', 'HEAD')).toThrow(/unexpected commit id/) + }) + + it('returns nothing for an empty range', () => { + expect(readCommitsSince(() => '', 'wiki-write-core-v0.0.0', 'HEAD')).toEqual([]) + }) +}) + +interface TestRepo { + readonly dir: string + readonly git: Git + commit: (message: string, ...files: string[]) => string + tag: (name: string, ref?: string) => void +} + +function createRepo(): TestRepo { + const dir = mkdtempSync(join(tmpdir(), 'wiki-write-core-release-repo-')) + const git: Git = args => + execFileSync('git', [...args], { + cwd: dir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + PATH: process.env.PATH ?? '', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }) + git(['init', '--quiet', '--initial-branch', 'main']) + let counter = 0 + return { + dir, + git, + commit: (message, ...files) => { + counter += 1 + for (const file of files) { + mkdirSync(dirname(join(dir, file)), {recursive: true}) + writeFileSync(join(dir, file), `${file} change ${counter}\n`) + } + git(['add', '--all']) + git(['commit', '--quiet', '--allow-empty', '--message', message]) + return git(['rev-parse', 'HEAD']).trim() + }, + tag: (name, ref = 'HEAD') => { + git(['tag', name, ref]) + }, + } +} + +describe('decideRelease', () => { + let repo: TestRepo + const analyzed: string[][] = [] + + const logger = {log: () => {}, warn: () => {}, error: () => {}, success: () => {}} + const releaserc = parseReleaserc(readFileSync(join(repositoryRoot, RELEASERC_FILE), 'utf8')) + + // Stand-ins that record what they were given, so these tests pin the commit selection alone. + const analyzeCommits: PluginStep = async (_config, context) => { + analyzed.push(context.commits.map(commit => commit.message)) + return 'minor' + } + const generateNotes: PluginStep = async (_config, context) => + `notes for ${context.nextRelease?.version ?? '?'}: ${context.commits.map(commit => commit.message).join(' | ')}` + + async function decide(ref = 'HEAD'): Promise { + return decideRelease({ + git: repo.git, + ref, + releaserc, + plugins: {analyzeCommits, generateNotes}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }) + } + + beforeEach(() => { + repo = createRepo() + analyzed.length = 0 + repo.commit('chore: seed', 'README.md') + repo.tag('wiki-write-core-v0.0.0') + }) + + afterEach(() => { + rmSync(repo.dir, {recursive: true, force: true}) + }) + + it('releases minor 0.1.0 from a feat commit touching the package, with notes for it', async () => { + repo.commit('feat: add the thing', PACKAGE_FILE) + + const decision = await decide() + + expect(decision).toMatchObject({ + release: true, + type: 'minor', + version: '0.1.0', + baseTag: 'wiki-write-core-v0.0.0', + notes: 'notes for 0.1.0: feat: add the thing', + }) + }) + + it('increments from the base tag by the analyzed type', async () => { + repo.commit('fix: first', PACKAGE_FILE) + repo.tag('wiki-write-core-v0.4.2') + repo.commit('fix: second', PACKAGE_FILE) + + const decision = await decide() + + expect(decision).toMatchObject({release: true, version: '0.5.0', baseTag: 'wiki-write-core-v0.4.2'}) + expect(analyzed).toEqual([['fix: second']]) + }) + + it('analyzes only the commits that touch the package: one of three here', async () => { + repo.commit('feat: scripts only', 'scripts/a.ts') + repo.commit('fix: the package', 'scripts/b.ts', PACKAGE_FILE) + repo.commit('feat: docs only', 'docs/c.md') + + const decision = await decide() + + expect(analyzed).toEqual([['fix: the package']]) + expect(decision).toMatchObject({release: true, notes: 'notes for 0.1.0: fix: the package'}) + }) + + it('releases nothing, and runs no plugin, when no commit since the base touches the package', async () => { + repo.commit('feat: scripts only', 'scripts/a.ts') + + const decision = await decide() + + expect(decision).toMatchObject({release: false, baseTag: 'wiki-write-core-v0.0.0'}) + expect(analyzed).toEqual([]) + }) + + it('does not count a change under a sibling directory that shares the package name as a prefix', async () => { + repo.commit('feat: sibling', 'packages/wiki-write-core-extra/src/x.ts') + + expect(await decide()).toMatchObject({release: false}) + }) + + it('excludes a feat commit marked [skip release]', async () => { + repo.commit('feat: hidden [skip release]', PACKAGE_FILE) + + expect(await decide()).toMatchObject({release: false}) + expect(analyzed).toEqual([]) + }) + + it('does not consider commits at or before the base tag', async () => { + repo.commit('feat: already released', PACKAGE_FILE) + repo.tag('wiki-write-core-v0.1.0') + + expect(await decide()).toMatchObject({release: false, baseTag: 'wiki-write-core-v0.1.0'}) + }) + + it('uses the highest version as the base, whatever order the tags were created in', async () => { + repo.commit('fix: one', PACKAGE_FILE) + repo.tag('wiki-write-core-v0.10.0') + repo.commit('fix: two', PACKAGE_FILE) + repo.tag('wiki-write-core-v0.2.0') + repo.commit('feat: three', PACKAGE_FILE) + + const decision = await decide() + + expect(decision).toMatchObject({release: true, version: '0.11.0', baseTag: 'wiki-write-core-v0.10.0'}) + // v0.2.0 sits above v0.10.0 in history but below it in version, so it must not shorten the range. + expect(analyzed).toEqual([['feat: three', 'fix: two']]) + }) + + it('ignores a release tag that is not reachable from the ref', async () => { + repo.git(['switch', '--quiet', '--create', 'side']) + repo.commit('chore: side', 'side.md') + repo.tag('wiki-write-core-v0.9.0') + repo.git(['switch', '--quiet', 'main']) + repo.commit('feat: on main', PACKAGE_FILE) + + expect(await decide()).toMatchObject({release: true, version: '0.1.0', baseTag: 'wiki-write-core-v0.0.0'}) + }) + + it('fails without a decision when no release tag is merged into the ref', async () => { + repo.git(['tag', '--delete', 'wiki-write-core-v0.0.0']) + repo.tag('other-v1.0.0') + repo.commit('feat: x', PACKAGE_FILE) + + await expect(decide()).rejects.toThrow(/no wiki-write-core-v\* release tag is reachable from HEAD/) + expect(analyzed).toEqual([]) + }) + + it('surfaces an unreadable changed-path list as a failed decision', async () => { + repo.commit('feat: x', PACKAGE_FILE) + const failingGit: Git = args => { + if (args[0] === 'diff') throw new Error('fatal: bad revision') + return repo.git(args) + } + + await expect( + decideRelease({ + git: failingGit, + ref: 'HEAD', + releaserc, + plugins: {analyzeCommits, generateNotes}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }), + ).rejects.toThrow(/cannot read the changed paths of commit/) + }) + + it('refuses a major release from a 0.x base, so the decision fails closed', async () => { + repo.commit('feat!: break the package', PACKAGE_FILE) + + await expect( + decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: {analyzeCommits: async () => 'major', generateNotes}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }), + ).rejects.toThrow('a 0.x package does not release a major version') + }) + + it('releases a major version from a base of 1.x or higher', async () => { + repo.commit('fix: reach 1.x', PACKAGE_FILE) + repo.tag('wiki-write-core-v1.2.0') + repo.commit('feat!: break the package', PACKAGE_FILE) + + const decision = await decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: {analyzeCommits: async () => 'major', generateNotes}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }) + + expect(decision).toMatchObject({release: true, type: 'major', version: '2.0.0', baseTag: 'wiki-write-core-v1.2.0'}) + }) + + it('rejects an analyzer result that is not a release type', async () => { + repo.commit('feat: x', PACKAGE_FILE) + + await expect( + decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: {analyzeCommits: async () => 'premajor', generateNotes}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }), + ).rejects.toThrow(/unexpected release type/) + }) + + it('rejects empty notes for a releasable decision', async () => { + repo.commit('feat: x', PACKAGE_FILE) + + await expect( + decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: {analyzeCommits, generateNotes: async () => ' '}, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }), + ).rejects.toThrow(/no notes/) + }) + + it('hands the analyzer the merged step options and the notes generator the release context', async () => { + repo.commit('feat: x', PACKAGE_FILE) + const head = repo.git(['rev-parse', 'HEAD']).trim() + const base = repo.git(['rev-parse', 'wiki-write-core-v0.0.0']).trim() + let analyzerConfig: Record = {} + let notesContext: Parameters[1] | undefined + + await decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: { + analyzeCommits: async config => { + analyzerConfig = config + return 'minor' + }, + generateNotes: async (_config, context) => { + notesContext = context + return 'notes' + }, + }, + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: repo.dir, + logger, + }) + + expect(analyzerConfig.preset).toBe('conventionalcommits') + expect(analyzerConfig.releaseRules).toEqual((releaserc.analyzeCommits as {releaseRules: unknown[]}).releaseRules) + expect(analyzerConfig).not.toHaveProperty('plugins') + expect(notesContext?.lastRelease).toEqual({version: '0.0.0', gitTag: 'wiki-write-core-v0.0.0', gitHead: base}) + expect(notesContext?.nextRelease).toEqual({version: '0.1.0', gitTag: 'wiki-write-core-v0.1.0', gitHead: head}) + expect(notesContext?.options).toEqual({repositoryUrl: 'https://github.com/fro-bot/.github.git'}) + }) +}) + +describe('formatGithubOutput', () => { + it('writes the type, version and base tag of a release', () => { + const decision: Decision = { + release: true, + type: 'minor', + version: '0.1.0', + baseTag: 'wiki-write-core-v0.0.0', + baseVersion: '0.0.0', + commits: [], + notes: 'n', + } + expect(formatGithubOutput(decision)).toBe('type=minor\nversion=0.1.0\nbase-tag=wiki-write-core-v0.0.0\n') + }) + + it('writes type=none and an empty version when nothing releases', () => { + const decision: Decision = {release: false, baseTag: 'wiki-write-core-v0.3.0', baseVersion: '0.3.0', commits: []} + expect(formatGithubOutput(decision)).toBe('type=none\nversion=\nbase-tag=wiki-write-core-v0.3.0\n') + }) +}) + +describe('parseReleaserc', () => { + it('accepts the committed policy', () => { + const releaserc = parseReleaserc(readFileSync(join(repositoryRoot, RELEASERC_FILE), 'utf8')) + + expect(releaserc.branches).toEqual(['main']) + expect(releaserc.tagFormat).toBe(TAG_FORMAT) + }) + + it.each([ + ['a different tag format', `branches: [main]\ntagFormat: '${TAG_FORMAT.replace('wiki-write-core-', '')}'\n`], + ['no tag format', 'branches: [main]\n'], + ['no branches', `tagFormat: '${TAG_FORMAT}'\n`], + ['empty branches', `branches: []\ntagFormat: '${TAG_FORMAT}'\n`], + ['a non-mapping document', '- a\n- b\n'], + ])('rejects %s', (_label, text) => { + expect(() => parseReleaserc(text)).toThrow() + }) +}) + +describe('release tool pins', () => { + const workflow = (overrides: Record = {}) => + [ + 'name: x', + 'env:', + ...RELEASE_TOOLS.map( + ({versionKey}) => ` ${versionKey}: ${overrides[versionKey] ?? '1.2.3'} # renovate: datasource=npm`, + ), + 'jobs: {}', + '', + ].join('\n') + + it('reads every pin from the workflow-level env', () => { + const pins = parseToolPins(workflow(), 'wf.yaml') + + expect(Object.keys(pins)).toEqual(RELEASE_TOOLS.map(({name}) => name)) + expect(new Set(Object.values(pins))).toEqual(new Set(['1.2.3'])) + }) + + it.each([ + ['a range', '^1.2.3'], + ['a tag', 'latest'], + ['a partial version', '1.2'], + ])('rejects %s', (_label, value) => { + expect(() => parseToolPins(workflow({SEMANTIC_RELEASE_NPM_VERSION: value}), 'wf.yaml')).toThrow( + /wf\.yaml: top-level env\.SEMANTIC_RELEASE_NPM_VERSION must be an exact x\.y\.z/, + ) + }) + + it('rejects a workflow with no env block', () => { + expect(() => parseToolPins('name: x\njobs: {}\n', 'wf.yaml')).toThrow(/SEMANTIC_RELEASE_VERSION/) + }) + + it('reads the pins declared in the workflow the scripts and CI both use', () => { + const pins = parseToolPins(readFileSync(join(repositoryRoot, TOOLS_WORKFLOW_FILE), 'utf8'), TOOLS_WORKFLOW_FILE) + + expect(pins['semantic-release']).toBe('25.0.9') + expect(pins['conventional-changelog-writer']).toBe('9.3.0') + }) +}) + +describe('scratch tool install', () => { + let prefix: string + const pins = Object.fromEntries(RELEASE_TOOLS.map(({name}, index) => [name, `1.0.${index}`])) as ToolPins + + function fakeInstall(): void { + for (const {name} of RELEASE_TOOLS) { + const directory = join(prefix, 'node_modules', name) + mkdirSync(directory, {recursive: true}) + writeFileSync(join(directory, 'package.json'), JSON.stringify({name, version: pins[name]})) + } + } + + beforeEach(() => { + prefix = mkdtempSync(join(tmpdir(), 'wiki-write-core-release-tools-')) + }) + + afterEach(() => { + rmSync(prefix, {recursive: true, force: true}) + }) + + it('writes a manifest with exact direct pins and a writer override equal to its direct pin', () => { + const manifest = JSON.parse(buildToolManifest(pins)) as { + dependencies: Record + overrides: Record + } + + expect(manifest.dependencies).toEqual(pins) + expect(manifest.overrides).toEqual({'conventional-changelog-writer': pins['conventional-changelog-writer']}) + }) + + it('installs with lifecycle scripts off and no lockfile, with the scratch prefix as the working directory', async () => { + const commands: InstallCommand[] = [] + + const outcome = await installReleaseTools(prefix, pins, command => { + commands.push(command) + fakeInstall() + return {status: 0} + }) + + expect(outcome).toBe('installed') + expect(commands).toEqual([ + { + command: 'npm', + args: ['install', '--prefix', prefix, '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund'], + cwd: prefix, + }, + ]) + expect(JSON.parse(readFileSync(join(prefix, 'package.json'), 'utf8'))).toMatchObject({dependencies: pins}) + }) + + it('runs nothing when the pinned versions are already installed', async () => { + fakeInstall() + const commands: InstallCommand[] = [] + + const outcome = await installReleaseTools(prefix, pins, command => { + commands.push(command) + return {status: 0} + }) + + expect(outcome).toBe('already-installed') + expect(commands).toEqual([]) + }) + + it('reinstalls when an installed version differs from its pin', async () => { + fakeInstall() + writeFileSync(join(prefix, 'node_modules', 'semantic-release', 'package.json'), JSON.stringify({version: '9.9.9'})) + const commands: InstallCommand[] = [] + + const outcome = await installReleaseTools(prefix, pins, command => { + commands.push(command) + fakeInstall() + return {status: 0} + }) + + expect(outcome).toBe('installed') + expect(commands).toHaveLength(1) + }) + + it('fails when npm exits non-zero or cannot start', async () => { + await expect(installReleaseTools(prefix, pins, () => ({status: 1}))).rejects.toThrow(/npm install exited 1/) + await expect( + installReleaseTools(prefix, pins, () => ({status: null, error: new Error('ENOENT npm')})), + ).rejects.toThrow(/npm install failed to start: ENOENT npm/) + }) + + it('fails when npm exits 0 but the pinned versions are not what landed', async () => { + await expect(installReleaseTools(prefix, pins, () => ({status: 0}))).rejects.toThrow(/is not installed under/) + }) + + it('fails loudly when the prefix has no install, naming the prefix variable', async () => { + await expect(assertToolsInstalled(join(prefix, 'missing'), pins)).rejects.toThrow( + new RegExp(`semantic-release is not installed under ${PREFIX_ENV}=`), + ) + await expect(loadReleasePlugins(join(prefix, 'missing'), pins)).rejects.toThrow(/is not installed under/) + }) + + it('fails loudly when an installed version differs from the pin', async () => { + fakeInstall() + writeFileSync( + join(prefix, 'node_modules', '@semantic-release', 'npm', 'package.json'), + JSON.stringify({version: '9.9.9'}), + ) + + await expect(assertToolsInstalled(prefix, pins)).rejects.toThrow( + /@semantic-release\/npm under .* is 9\.9\.9 but the workflow pins/, + ) + }) +}) + +describe('resolvePrefix', () => { + it('prefers the prefix variable, treats empty as unset, then falls back under the runner temp dir', () => { + expect(resolvePrefix({[PREFIX_ENV]: '/custom', RUNNER_TEMP: '/runner'})).toBe('/custom') + expect(resolvePrefix({[PREFIX_ENV]: '', RUNNER_TEMP: '/runner'})).toBe( + join('/runner', 'wiki-write-core-release-tools'), + ) + expect(resolvePrefix({RUNNER_TEMP: '/runner'})).toBe(join('/runner', 'wiki-write-core-release-tools')) + expect(resolvePrefix({})).toBe(join(tmpdir(), 'wiki-write-core-release-tools')) + }) +}) + +// Runs the script as a child process, the way the workflow does, against a real git repository and a +// scratch prefix that holds stand-in plugins pinned to the versions the workflow declares. +describe('command line', () => { + const scriptPath = join(repositoryRoot, 'scripts/wiki-write-core-release.ts') + const workflowFile = join(repositoryRoot, TOOLS_WORKFLOW_FILE) + const pins = parseToolPins(readFileSync(workflowFile, 'utf8'), TOOLS_WORKFLOW_FILE) + const releasercText = readFileSync(join(repositoryRoot, RELEASERC_FILE), 'utf8') + + let repo: TestRepo + let scratch: string + let prefix: string + + /** Installs every pinned tool as an empty package; the two plugins get a fake implementation. */ + function installFakeTools(): void { + for (const {name} of RELEASE_TOOLS) { + const directory = join(prefix, 'node_modules', name) + mkdirSync(directory, {recursive: true}) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({name, version: pins[name], type: 'module', main: 'index.js'}), + ) + writeFileSync(join(directory, 'index.js'), 'export {}\n') + } + writeFileSync( + join(prefix, 'node_modules', '@semantic-release', 'commit-analyzer', 'index.js'), + 'export const analyzeCommits = async () => process.env.FAKE_RELEASE_TYPE || null\n', + ) + writeFileSync( + join(prefix, 'node_modules', '@semantic-release', 'release-notes-generator', 'index.js'), + "export const generateNotes = async (_config, context) => '## ' + context.nextRelease.version + '\\n\\nfake notes\\n\\n'\n", + ) + } + + function runCli( + script: string, + args: readonly string[], + extraEnv: Record = {}, + ): {status: number | null; stdout: string; stderr: string} { + const result = spawnSync(process.execPath, [script, ...args], { + cwd: repo.dir, + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + [PREFIX_ENV]: prefix, + ...extraEnv, + }, + }) + return {status: result.status, stdout: result.stdout, stderr: result.stderr} + } + + function decide(extraEnv: Record = {}, script = scriptPath, notesFile = join(scratch, 'notes.md')) { + return runCli(script, ['decide', '--workflow', workflowFile, '--notes', notesFile], extraEnv) + } + + beforeEach(() => { + repo = createRepo() + scratch = mkdtempSync(join(tmpdir(), 'wiki-write-core-release-cli-')) + prefix = join(scratch, 'prefix') + installFakeTools() + repo.git(['remote', 'add', 'origin', 'https://github.com/fro-bot/.github.git']) + mkdirSync(join(repo.dir, dirname(RELEASERC_FILE)), {recursive: true}) + writeFileSync(join(repo.dir, RELEASERC_FILE), releasercText) + repo.commit('chore: seed', 'README.md') + repo.tag('wiki-write-core-v0.0.0') + }) + + afterEach(() => { + rmSync(repo.dir, {recursive: true, force: true}) + rmSync(scratch, {recursive: true, force: true}) + }) + + it('runs main() when the script path contains a space', () => { + const spaced = join(scratch, 'a directory with spaces') + mkdirSync(spaced) + // `yaml` resolves through the repository's node_modules, so the copy links to it. + symlinkSync(join(repositoryRoot, 'node_modules'), join(spaced, 'node_modules')) + const copy = join(spaced, 'wiki-write-core-release.ts') + copyFileSync(scriptPath, copy) + repo.git(['tag', '--delete', 'wiki-write-core-v0.0.0']) + + const result = decide({}, copy) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('no wiki-write-core-v* release tag is reachable from HEAD') + }) + + it('appends type, version and base-tag to GITHUB_OUTPUT, prints them, and writes the notes file', () => { + repo.commit('feat: add the thing', PACKAGE_FILE) + const githubOutput = join(scratch, 'github-output') + writeFileSync(githubOutput, 'existing=1\n') + const notesFile = join(scratch, 'nested', 'notes.md') + + const result = decide({GITHUB_OUTPUT: githubOutput, FAKE_RELEASE_TYPE: 'minor'}, scriptPath, notesFile) + + const expected = 'type=minor\nversion=0.1.0\nbase-tag=wiki-write-core-v0.0.0\n' + expect(result.status).toBe(0) + expect(result.stdout).toBe(expected) + expect(readFileSync(githubOutput, 'utf8')).toBe(`existing=1\n${expected}`) + expect(readFileSync(notesFile, 'utf8')).toBe('## 0.1.0\n\nfake notes\n') + }) + + it('reports type=none and writes no notes when nothing releases', () => { + repo.commit('feat: scripts only', 'scripts/a.ts') + const githubOutput = join(scratch, 'github-output') + const notesFile = join(scratch, 'notes.md') + + const result = decide({GITHUB_OUTPUT: githubOutput, FAKE_RELEASE_TYPE: 'minor'}, scriptPath, notesFile) + + const expected = 'type=none\nversion=\nbase-tag=wiki-write-core-v0.0.0\n' + expect(result.status).toBe(0) + expect(result.stdout).toBe(expected) + expect(readFileSync(githubOutput, 'utf8')).toBe(expected) + expect(existsSync(notesFile)).toBe(false) + }) + + it('reports type=none when the analyzer finds no releasable commit', () => { + repo.commit('chore: tidy the package', PACKAGE_FILE) + + const result = decide() + + expect(result.status).toBe(0) + expect(result.stdout).toBe('type=none\nversion=\nbase-tag=wiki-write-core-v0.0.0\n') + }) + + it('writes nothing to GITHUB_OUTPUT when the variable is unset', () => { + repo.commit('feat: add the thing', PACKAGE_FILE) + + const result = decide({FAKE_RELEASE_TYPE: 'patch'}) + + expect(result.status).toBe(0) + expect(result.stdout).toBe('type=patch\nversion=0.0.1\nbase-tag=wiki-write-core-v0.0.0\n') + }) + + it('fails closed, with no output and no notes, on a major release from a 0.x base', () => { + repo.commit('feat!: break the package', PACKAGE_FILE) + const githubOutput = join(scratch, 'github-output') + const notesFile = join(scratch, 'notes.md') + + const result = decide({GITHUB_OUTPUT: githubOutput, FAKE_RELEASE_TYPE: 'major'}, scriptPath, notesFile) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('a 0.x package does not release a major version') + expect(existsSync(githubOutput)).toBe(false) + expect(existsSync(notesFile)).toBe(false) + }) + + it.each([ + ['an unknown command', ['bogus'], /usage: wiki-write-core-release\.ts /], + ['no command', [], /usage: wiki-write-core-release\.ts/], + ['an unknown flag', ['decide', '--bogus', 'x'], /decide: unknown argument "--bogus"/], + ['a flag allowed only for another command', ['install', '--notes', 'x'], /install: unknown argument "--notes"/], + ['a flag without a value', ['decide', '--notes'], /decide: --notes requires a value/], + ['decide without --notes', ['decide', '--workflow', workflowFile], /decide: --notes is required/], + ])('exits 1 with a message for %s', (_label, args, message) => { + const result = runCli(scriptPath, args) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(message) + expect(result.stdout).toBe('') + }) + + it('prints the prefix and installs nothing when the pinned tools are already present', () => { + const result = runCli(scriptPath, ['install', '--workflow', workflowFile]) + + expect(result.status).toBe(0) + expect(result.stdout).toBe(`${prefix}\n`) + expect(result.stderr).toContain('already-installed') + }) +}) + +// Needs the pinned install in WIKI_WRITE_CORE_RELEASE_PREFIX (the `Check Workflows` job provides it +// through `node scripts/wiki-write-core-release.ts install`); skipped by a plain `pnpm test`. With the +// variable set but nothing installed there, every case fails. +describe.skipIf(process.env[PREFIX_ENV] === undefined)('against the pinned release tools', () => { + const prefix = resolvePrefix() + const pins = parseToolPins(readFileSync(join(repositoryRoot, TOOLS_WORKFLOW_FILE), 'utf8'), TOOLS_WORKFLOW_FILE) + const releaserc = parseReleaserc(readFileSync(join(repositoryRoot, RELEASERC_FILE), 'utf8')) + const logger = {log: () => {}, warn: () => {}, error: () => {}, success: () => {}} + + let repo: TestRepo + + beforeEach(() => { + repo = createRepo() + repo.commit('chore: seed', 'README.md') + repo.tag('wiki-write-core-v0.0.0') + }) + + afterEach(() => { + rmSync(repo.dir, {recursive: true, force: true}) + }) + + async function decideWithRealPlugins(): Promise { + return decideRelease({ + git: repo.git, + ref: 'HEAD', + releaserc, + plugins: await loadReleasePlugins(prefix, pins), + repositoryUrl: 'https://github.com/fro-bot/.github.git', + cwd: prefix, + logger, + }) + } + + it('has the exact pinned versions installed, with the writer override applied', async () => { + await assertToolsInstalled(prefix, pins) + const generatorManifest = join(prefix, 'node_modules', '@semantic-release', 'release-notes-generator') + + expect(() => + readFileSync(join(generatorManifest, 'node_modules', 'conventional-changelog-writer', 'package.json')), + ).toThrow(/ENOENT/) + }) + + it.each([ + ['feat: add a thing', 'minor', '0.1.0'], + ['feat(api): add a scoped thing', 'minor', '0.1.0'], + ['fix: repair a thing', 'patch', '0.0.1'], + ['build: change the build', 'patch', '0.0.1'], + ['build(deps): bump a runtime dependency', 'patch', '0.0.1'], + ['docs(readme): describe a thing', 'patch', '0.0.1'], + ['feat!: rename a thing', 'minor', '0.1.0'], + ['fix!: change a fix', 'minor', '0.1.0'], + ['feat: add a thing\n\nBREAKING CHANGE: the old thing is gone', 'minor', '0.1.0'], + ] as const)('maps %j to %s', async (message, type, version) => { + repo.commit(message, PACKAGE_FILE) + + expect(await decideWithRealPlugins()).toMatchObject({release: true, type, version}) + }) + + it.each([ + ['build(dev): bump a dev dependency'], + ['build(dev)!: breaking dev change'], + ['docs: describe a thing elsewhere'], + ['docs(rfcs): describe a proposal'], + ['skip: a skipped commit'], + ['chore: tidy'], + ['test: cover a thing'], + ['ci: change CI'], + ['refactor: restructure'], + ['not a conventional commit'], + ])('releases nothing for %j', async message => { + repo.commit(message, PACKAGE_FILE) + + expect(await decideWithRealPlugins()).toMatchObject({release: false}) + }) + + it('writes notes for only the commits that touch the package', async () => { + repo.commit('feat: scripts only feature', 'scripts/a.ts') + repo.commit('fix: package fix', 'scripts/b.ts', PACKAGE_FILE) + repo.commit('feat(api): package feature', PACKAGE_FILE) + repo.commit('fix: workflow only fix', '.github/workflows/x.yaml') + + const decision = await decideWithRealPlugins() + + expect(decision).toMatchObject({release: true, type: 'minor', version: '0.1.0'}) + const notes = decision.release ? decision.notes : '' + expect(notes).toContain( + '## [0.1.0](https://github.com/fro-bot/.github/compare/wiki-write-core-v0.0.0...wiki-write-core-v0.1.0)', + ) + expect(notes).toContain('package fix') + expect(notes).toContain('package feature') + expect(notes).not.toContain('scripts only feature') + expect(notes).not.toContain('workflow only fix') + }) +}) diff --git a/scripts/wiki-write-core-release.ts b/scripts/wiki-write-core-release.ts new file mode 100644 index 000000000..e0bcae530 --- /dev/null +++ b/scripts/wiki-write-core-release.ts @@ -0,0 +1,556 @@ +import {execFileSync, spawnSync} from 'node:child_process' +import {appendFileSync} from 'node:fs' +import {mkdir, readFile, writeFile} from 'node:fs/promises' +import {createRequire} from 'node:module' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import process from 'node:process' +import {pathToFileURL} from 'node:url' +import {format} from 'node:util' + +import {parse as parseYaml} from 'yaml' + +/** + * Release decision for `@fro.bot/wiki-write-core`. + * + * semantic-release sees every commit since the last tag, so it cannot scope a release to one + * package of this repository. This module does the scoping in the credential-free `decide` job of + * `.github/workflows/release-wiki-write-core.yaml`: it takes the highest `wiki-write-core-v*` tag + * merged into the ref as the base, keeps the commits since that tag that changed a path under + * `packages/wiki-write-core/` (minus `[skip release]` commits), and runs the configured + * `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator` over only + * those commits. The type, version and base tag go out as job outputs; the notes go to a file. + * + * Release tools are NOT dependencies of this repository: their transitive tree carries advisories + * with no published fix, which `pnpm lint` (`check:override-floors`) fails on. One exact version of + * each is installed into a scratch prefix outside the repo (`install`), and the two plugins are + * loaded from there. The versions are declared in the top-level `env` of a workflow file, each with a + * `# renovate: datasource=npm depName=...` comment that the inherited bfra-me regex manager keeps + * current; this module reads them from the file so local runs and CI cannot disagree. + */ + +export const PACKAGE_PATH_PREFIX = 'packages/wiki-write-core/' +export const TAG_PREFIX = 'wiki-write-core-v' +export const RELEASERC_FILE = 'packages/wiki-write-core/.releaserc.yaml' +export const TOOLS_WORKFLOW_FILE = '.github/workflows/release-wiki-write-core.yaml' +export const PREFIX_ENV = 'WIKI_WRITE_CORE_RELEASE_PREFIX' + +/** The semantic-release `tagFormat` template that produces `TAG_PREFIX` tags. */ +export const TAG_FORMAT = `${TAG_PREFIX}\${version}` +const EXACT_VERSION = /^\d+\.\d+\.\d+$/u +const TAG_PATTERN = /^wiki-write-core-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u +const SKIP_RELEASE_PATTERN = /\[skip release\]|\[release skip\]/iu +const COMMIT_HASH_PATTERN = /^[0-9a-f]{40,64}$/u + +/** The release tools, in install order, with the workflow `env` key that pins each. */ +export const RELEASE_TOOLS = [ + {name: 'semantic-release', versionKey: 'SEMANTIC_RELEASE_VERSION'}, + {name: '@semantic-release/exec', versionKey: 'SEMANTIC_RELEASE_EXEC_VERSION'}, + {name: '@semantic-release/npm', versionKey: 'SEMANTIC_RELEASE_NPM_VERSION'}, + {name: '@semantic-release/github', versionKey: 'SEMANTIC_RELEASE_GITHUB_VERSION'}, + {name: '@semantic-release/commit-analyzer', versionKey: 'SEMANTIC_RELEASE_COMMIT_ANALYZER_VERSION'}, + {name: '@semantic-release/release-notes-generator', versionKey: 'SEMANTIC_RELEASE_NOTES_GENERATOR_VERSION'}, + { + name: 'conventional-changelog-conventionalcommits', + versionKey: 'CONVENTIONAL_CHANGELOG_CONVENTIONALCOMMITS_VERSION', + }, + {name: 'conventional-changelog-writer', versionKey: 'CONVENTIONAL_CHANGELOG_WRITER_VERSION'}, +] as const + +/** + * `@semantic-release/release-notes-generator` depends on `conventional-changelog-writer` ^8, which the + * `conventionalcommits` 10 preset rejects; the manifest pins writer 9 both directly and as an override. + */ +const WRITER_NAME = 'conventional-changelog-writer' + +export type ReleaseTool = (typeof RELEASE_TOOLS)[number]['name'] +export type ToolPins = Readonly> +export type ReleaseType = 'major' | 'minor' | 'patch' + +export interface ReleaseCommit { + readonly hash: string + readonly message: string + readonly committerDate: string + /** Every path the commit changed relative to its first parent, renames reported as delete plus add. */ + readonly paths: readonly string[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +// -- base tag and version arithmetic --------------------------------------------------------------- + +function parseTagVersion(tag: string): readonly [number, number, number] | undefined { + const match = TAG_PATTERN.exec(tag) + if (match === null) return undefined + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function compareVersions(left: readonly number[], right: readonly number[]): number { + for (const [index, part] of left.entries()) { + const difference = part - (right[index] ?? 0) + if (difference !== 0) return difference + } + return 0 +} + +/** + * The highest-semver release tag, or `undefined` when none qualifies. Compares numerically, so the + * order in which tags were created is irrelevant and `0.10.0` outranks `0.2.0`. Prerelease, build + * metadata, leading-zero and otherwise malformed tags are not release tags. + */ +export function selectBaseTag(tags: readonly string[]): string | undefined { + let best: {tag: string; version: readonly [number, number, number]} | undefined + for (const tag of tags) { + const version = parseTagVersion(tag) + if (version === undefined) continue + if (best === undefined || compareVersions(version, best.version) > 0) best = {tag, version} + } + return best?.tag +} + +export function incrementVersion(version: string, type: ReleaseType): string { + const [major = 0, minor = 0, patch = 0] = version.split('.').map(Number) + if (type === 'major') { + if (major === 0) throw new Error('a 0.x package does not release a major version') + return `${major + 1}.0.0` + } + if (type === 'minor') return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` +} + +// -- commit selection ------------------------------------------------------------------------------ + +/** + * The commits that count toward a release: those that changed a path under the package directory and + * are not marked `[skip release]` / `[release skip]` (the rule semantic-release applies itself). + */ +export function filterPackageCommits(commits: readonly ReleaseCommit[]): ReleaseCommit[] { + return commits.filter( + commit => + !SKIP_RELEASE_PATTERN.test(commit.message) && commit.paths.some(path => path.startsWith(PACKAGE_PATH_PREFIX)), + ) +} + +/** Runs `git` with `args` and returns stdout; throws on a non-zero exit. */ +export type Git = (args: readonly string[]) => string + +export const runGit: Git = args => { + try { + return execFileSync('git', [...args], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (error) { + const stderr = isRecord(error) && typeof error.stderr === 'string' ? error.stderr.trim() : '' + throw new Error(`git ${args.join(' ')} failed: ${stderr === '' ? errorMessage(error) : stderr}`, {cause: error}) + } +} + +/** The release tags reachable from `ref`, which is what bounds the base tag to the branch history. */ +export function listMergedTags(git: Git, ref: string): string[] { + return git(['tag', '--merged', ref, '--list', `${TAG_PREFIX}*`]) + .split('\n') + .filter(tag => tag !== '') +} + +/** + * Every commit in `baseTag..ref`, newest first, with the paths each changed against its first parent. + * Throws when a commit's paths cannot be read: a commit with unknown paths must not silently drop out. + */ +export function readCommitsSince(git: Git, baseTag: string, ref: string): ReleaseCommit[] { + const hashes = git(['rev-list', `${baseTag}..${ref}`, '--']) + .split('\n') + .filter(hash => hash !== '') + return hashes.map(hash => { + if (!COMMIT_HASH_PATTERN.test(hash)) throw new Error(`git rev-list returned an unexpected commit id: ${hash}`) + const [committerDate = '', ...messageLines] = git(['log', '-1', '--format=%cI%n%B', hash]).split('\n') + let paths: string[] + try { + paths = git(['diff', '--name-only', '--no-renames', '-z', `${hash}^1`, hash, '--']) + .split('\0') + .filter(path => path !== '') + } catch (error) { + throw new Error(`cannot read the changed paths of commit ${hash}: ${errorMessage(error)}`, {cause: error}) + } + return {hash, message: messageLines.join('\n').trimEnd(), committerDate, paths} + }) +} + +// -- release policy -------------------------------------------------------------------------------- + +export type Releaserc = Readonly> + +/** Parses `.releaserc.yaml` and checks the fields this module relies on. */ +export function parseReleaserc(text: string): Releaserc { + const document: unknown = parseYaml(text) + if (!isRecord(document)) throw new TypeError(`${RELEASERC_FILE} must contain a mapping at the top level`) + if (document.tagFormat !== TAG_FORMAT) { + throw new TypeError( + `${RELEASERC_FILE}: tagFormat must be ${JSON.stringify(TAG_FORMAT)}, got ${JSON.stringify(document.tagFormat)}`, + ) + } + if (!Array.isArray(document.branches) || document.branches.length === 0) { + throw new TypeError(`${RELEASERC_FILE}: branches must be a non-empty list`) + } + return document +} + +/** + * semantic-release hands each plugin the global options merged with the step's own options; a + * step key holding a plain object (`analyzeCommits: {releaseRules}`) is merged into that step's plugin. + */ +function pluginConfig(releaserc: Releaserc, step: 'analyzeCommits' | 'generateNotes'): Record { + const shared = Object.fromEntries( + Object.entries(releaserc).filter(([key]) => !['plugins', 'analyzeCommits', 'generateNotes'].includes(key)), + ) + const own = releaserc[step] + return {...shared, ...(isRecord(own) ? own : {})} +} + +// -- decision -------------------------------------------------------------------------------------- + +export interface PluginLogger { + log: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void + success: (...args: unknown[]) => void +} + +export interface PluginCommit { + readonly hash: string + readonly message: string + readonly committerDate: string +} + +export interface PluginContext { + readonly cwd: string + readonly logger: PluginLogger + readonly commits: readonly PluginCommit[] + readonly options?: {readonly repositoryUrl: string} + readonly lastRelease?: {readonly version: string; readonly gitTag: string; readonly gitHead: string} + readonly nextRelease?: {readonly version: string; readonly gitTag: string; readonly gitHead: string} +} + +export type PluginStep = (config: Record, context: PluginContext) => Promise + +export interface ReleasePlugins { + readonly analyzeCommits: PluginStep + readonly generateNotes: PluginStep +} + +export type Decision = + | { + readonly release: false + readonly baseTag: string + readonly baseVersion: string + readonly commits: readonly ReleaseCommit[] + } + | { + readonly release: true + readonly type: ReleaseType + readonly version: string + readonly baseTag: string + readonly baseVersion: string + readonly commits: readonly ReleaseCommit[] + readonly notes: string + } + +export interface DecideOptions { + readonly git: Git + readonly ref: string + readonly releaserc: Releaserc + readonly plugins: ReleasePlugins + readonly repositoryUrl: string + /** Directory the plugins resolve presets from: the scratch prefix that holds them. */ + readonly cwd: string + readonly logger: PluginLogger +} + +function asReleaseType(value: unknown): ReleaseType | undefined { + if (value === null || value === undefined) return undefined + if (value === 'major' || value === 'minor' || value === 'patch') return value + throw new TypeError(`commit-analyzer returned an unexpected release type: ${JSON.stringify(value)}`) +} + +export async function decideRelease(options: DecideOptions): Promise { + const {git, ref, releaserc, plugins, repositoryUrl, cwd, logger} = options + + const baseTag = selectBaseTag(listMergedTags(git, ref)) + if (baseTag === undefined) { + throw new Error( + `no ${TAG_PREFIX}* release tag is reachable from ${ref}; refusing to decide a release without a base tag`, + ) + } + const baseVersion = baseTag.slice(TAG_PREFIX.length) + + const commits = filterPackageCommits(readCommitsSince(git, baseTag, ref)) + const pluginCommits = commits.map(({hash, message, committerDate}) => ({hash, message, committerDate})) + + const type = + commits.length === 0 + ? undefined + : asReleaseType( + await plugins.analyzeCommits(pluginConfig(releaserc, 'analyzeCommits'), { + cwd, + commits: pluginCommits, + logger, + }), + ) + if (type === undefined) return {release: false, baseTag, baseVersion, commits} + + const version = incrementVersion(baseVersion, type) + const notes = await plugins.generateNotes(pluginConfig(releaserc, 'generateNotes'), { + cwd, + commits: pluginCommits, + logger, + options: {repositoryUrl}, + lastRelease: {version: baseVersion, gitTag: baseTag, gitHead: git(['rev-parse', `${baseTag}^{commit}`]).trim()}, + nextRelease: {version, gitTag: `${TAG_PREFIX}${version}`, gitHead: git(['rev-parse', `${ref}^{commit}`]).trim()}, + }) + if (typeof notes !== 'string' || notes.trim() === '') { + throw new TypeError('release-notes-generator returned no notes for a releasable decision') + } + return {release: true, type, version, baseTag, baseVersion, commits, notes} +} + +/** The `$GITHUB_OUTPUT` lines for a decision. `type=none` means nothing to release. */ +export function formatGithubOutput(decision: Decision): string { + return decision.release + ? `type=${decision.type}\nversion=${decision.version}\nbase-tag=${decision.baseTag}\n` + : `type=none\nversion=\nbase-tag=${decision.baseTag}\n` +} + +// -- release tools: pins, scratch install, plugin loading ------------------------------------------ + +/** Extracts every tool pin from the top-level `env` of a workflow file's text; throws unless each is exact. */ +export function parseToolPins(workflowText: string, source: string): ToolPins { + const document: unknown = parseYaml(workflowText) + const env = isRecord(document) ? document.env : undefined + const pins: Record = {} + for (const {name, versionKey} of RELEASE_TOOLS) { + const version = isRecord(env) ? env[versionKey] : undefined + if (typeof version !== 'string' || !EXACT_VERSION.test(version)) { + throw new TypeError( + `${source}: top-level env.${versionKey} must be an exact x.y.z version string, got ${JSON.stringify(version)}`, + ) + } + pins[name] = version + } + return pins as ToolPins +} + +/** `WIKI_WRITE_CORE_RELEASE_PREFIX` when set, else a directory under the runner temp dir. */ +export function resolvePrefix(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env[PREFIX_ENV] + if (explicit !== undefined && explicit !== '') return explicit + return join(env.RUNNER_TEMP ?? tmpdir(), 'wiki-write-core-release-tools') +} + +/** The scratch `package.json`: exact direct pins plus the writer override that matches its direct pin. */ +export function buildToolManifest(pins: ToolPins): string { + return `${JSON.stringify( + { + name: 'wiki-write-core-release-tools', + private: true, + dependencies: Object.fromEntries(RELEASE_TOOLS.map(({name}) => [name, pins[name]])), + overrides: {[WRITER_NAME]: pins[WRITER_NAME]}, + }, + null, + 2, + )}\n` +} + +async function readInstalledVersion(prefix: string, name: string): Promise { + try { + const manifest: unknown = JSON.parse(await readFile(join(prefix, 'node_modules', name, 'package.json'), 'utf8')) + return isRecord(manifest) && typeof manifest.version === 'string' ? manifest.version : undefined + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined + throw error + } +} + +/** Fails loudly unless `prefix` holds exactly the pinned version of every release tool. */ +export async function assertToolsInstalled(prefix: string, pins: ToolPins): Promise { + for (const {name} of RELEASE_TOOLS) { + const installed = await readInstalledVersion(prefix, name) + if (installed === undefined) { + throw new Error( + `${name} is not installed under ${PREFIX_ENV}=${prefix}. Run \`node scripts/wiki-write-core-release.ts install\` first.`, + ) + } + if (installed !== pins[name]) { + throw new Error( + `${name} under ${prefix} is ${installed} but the workflow pins ${pins[name]}. Re-run \`node scripts/wiki-write-core-release.ts install\`.`, + ) + } + } +} + +export interface InstallCommand { + readonly command: string + readonly args: readonly string[] + readonly cwd: string +} + +export type RunInstall = (command: InstallCommand) => {status: number | null; error?: Error | undefined} + +const runInstallCommand: RunInstall = ({command, args, cwd}) => + // stdout is reserved for the prefix path; npm progress goes to stderr. + spawnSync(command, [...args], {cwd, stdio: ['ignore', process.stderr, process.stderr]}) + +/** + * Installs the pinned tools into `prefix` with lifecycle scripts off. `cwd` is the prefix itself and + * `--no-package-lock` keeps npm from writing a lockfile, so the repository's `package.json` and + * `pnpm-lock.yaml` are never touched. + */ +export async function installReleaseTools( + prefix: string, + pins: ToolPins, + run: RunInstall = runInstallCommand, +): Promise<'already-installed' | 'installed'> { + try { + await assertToolsInstalled(prefix, pins) + return 'already-installed' + } catch { + // Missing or different versions: fall through to a fresh install. + } + + await mkdir(prefix, {recursive: true}) + await writeFile(join(prefix, 'package.json'), buildToolManifest(pins)) + const result = run({ + command: 'npm', + args: ['install', '--prefix', prefix, '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund'], + cwd: prefix, + }) + if (result.error !== undefined) throw new Error(`npm install failed to start: ${errorMessage(result.error)}`) + if (result.status !== 0) throw new Error(`npm install exited ${String(result.status)}`) + // The explicit post-condition: npm exiting 0 is not proof the pinned versions landed. + await assertToolsInstalled(prefix, pins) + return 'installed' +} + +function pickFunction(source: unknown, name: string, origin: string): PluginStep { + const candidate = isRecord(source) ? source[name] : undefined + if (typeof candidate !== 'function') throw new TypeError(`${origin} does not export a function named ${name}`) + return candidate as PluginStep +} + +/** Loads the two semantic-release plugins from the scratch prefix. Fails unless the pinned install is present. */ +export async function loadReleasePlugins(prefix: string, pins: ToolPins): Promise { + await assertToolsInstalled(prefix, pins) + const requireFromPrefix = createRequire(join(prefix, 'package.json')) + const analyzer: unknown = await import( + pathToFileURL(requireFromPrefix.resolve('@semantic-release/commit-analyzer')).href + ) + const notes: unknown = await import( + pathToFileURL(requireFromPrefix.resolve('@semantic-release/release-notes-generator')).href + ) + return { + analyzeCommits: pickFunction(analyzer, 'analyzeCommits', '@semantic-release/commit-analyzer'), + generateNotes: pickFunction(notes, 'generateNotes', '@semantic-release/release-notes-generator'), + } +} + +// -- CLI ------------------------------------------------------------------------------------------- + +/** Plugins log printf-style (`logger.log('Analyzing commit: %s', message)`), so route through `util.format`. */ +function writeLog(...args: unknown[]): void { + process.stderr.write(`${format(...args)}\n`) +} + +const stderrLogger: PluginLogger = {log: writeLog, warn: writeLog, error: writeLog, success: writeLog} + +interface CliArguments { + readonly command: string + readonly flags: ReadonlyMap +} + +const FLAGS_BY_COMMAND: Readonly> = { + install: ['--workflow'], + decide: ['--workflow', '--notes', '--ref'], +} + +function parseArguments(argv: readonly string[]): CliArguments { + const [command = '', ...rest] = argv + const allowed = FLAGS_BY_COMMAND[command] + if (allowed === undefined) + throw new Error(`usage: wiki-write-core-release.ts <${Object.keys(FLAGS_BY_COMMAND).join('|')}> [flags]`) + const flags = new Map() + for (let index = 0; index < rest.length; index += 2) { + const flag = rest[index] ?? '' + const value = rest[index + 1] + if (!allowed.includes(flag)) throw new Error(`${command}: unknown argument ${JSON.stringify(flag)}`) + if (value === undefined) throw new Error(`${command}: ${flag} requires a value`) + flags.set(flag, value) + } + return {command, flags} +} + +function logDecision(decision: Decision): void { + const lines = [ + `wiki-write-core-release: base ${decision.baseTag}, ${decision.commits.length} package commit(s) since`, + ] + for (const commit of decision.commits) + lines.push(` ${commit.hash.slice(0, 7)} ${commit.message.split('\n')[0] ?? ''}`) + lines.push( + decision.release + ? `wiki-write-core-release: ${decision.type} release ${decision.version}` + : 'wiki-write-core-release: no release', + ) + process.stderr.write(`${lines.join('\n')}\n`) +} + +export async function main( + argv: readonly string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + const {command, flags} = parseArguments(argv) + const workflowFile = flags.get('--workflow') ?? TOOLS_WORKFLOW_FILE + const pins = parseToolPins(await readFile(workflowFile, 'utf8'), workflowFile) + const prefix = resolvePrefix(env) + + if (command === 'install') { + const outcome = await installReleaseTools(prefix, pins) + process.stderr.write(`wiki-write-core-release: release tools ${outcome} at ${prefix}\n`) + process.stdout.write(`${prefix}\n`) + return + } + + const notesFile = flags.get('--notes') + if (notesFile === undefined) throw new Error('decide: --notes is required') + const ref = flags.get('--ref') ?? 'HEAD' + const decision = await decideRelease({ + git: runGit, + ref, + releaserc: parseReleaserc(await readFile(RELEASERC_FILE, 'utf8')), + plugins: await loadReleasePlugins(prefix, pins), + repositoryUrl: runGit(['remote', 'get-url', 'origin']).trim(), + cwd: prefix, + logger: stderrLogger, + }) + logDecision(decision) + if (decision.release) { + await mkdir(dirname(notesFile), {recursive: true}) + await writeFile(notesFile, `${decision.notes.trimEnd()}\n`) + } + const githubOutput = env.GITHUB_OUTPUT + if (githubOutput !== undefined && githubOutput !== '') appendFileSync(githubOutput, formatGithubOutput(decision)) + process.stdout.write(formatGithubOutput(decision)) + } catch (error) { + process.stderr.write(`wiki-write-core-release: ${errorMessage(error)}\n`) + process.exitCode = 1 + } +} + +if (import.meta.main) { + await main() +}