diff --git a/.github/workflows/close-draft-prs-sweep.yml b/.github/workflows/close-draft-prs-sweep.yml new file mode 100644 index 000000000..160bea5f7 --- /dev/null +++ b/.github/workflows/close-draft-prs-sweep.yml @@ -0,0 +1,112 @@ +name: Close Draft PRs (sweep) + +# Companion to close-draft-prs.yml. That workflow runs per-PR on +# pull_request_target and is the fast path. GitHub does NOT dispatch +# pull_request_target for fork branches whose names look like a Git SHA, so a +# fork draft PR on a SHA-named branch can never be closed by any PR-triggered +# event (a pull_request run from a fork gets a read-only token). This scheduled +# sweep runs in the base-repo context with a write-capable token and enforces +# the identical policy on a timer, catching that evasion. See issue #761. + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +concurrency: + group: close-draft-prs-sweep + cancel-in-progress: false + +permissions: + pull-requests: write + +jobs: + sweep-draft-prs: + name: Sweep open draft PRs + runs-on: ubuntu-latest + steps: + - name: Close non-maintainer draft PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + // Maintainers may use draft PRs for internal coordination — same + // carve-out as close-draft-prs.yml. A scheduled run has no + // github.event.pull_request, so the carve-out is applied here as a + // Set membership test over author_association. + const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const repoUrl = context.repo.owner + '/' + context.repo.repo; + + const commentBody = [ + '## Draft PRs are not accepted', + '', + 'This project only accepts completed pull requests. Draft PRs are automatically closed.', + '', + '**Why?** GSD requires all PRs to be ready for review when opened \u2014 with tests passing, the correct PR template used, and a linked approved issue. Draft PRs bypass these quality gates and create review overhead.', + '', + '### What to do instead', + '', + '1. Finish your implementation locally', + '2. Run `npm run test:coverage` and confirm all tests pass', + '3. Open a **non-draft** PR using the [correct template](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md#pull-request-guidelines)', + '', + 'See [CONTRIBUTING.md](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md) for the full process.', + ].join('\n'); + + const isEnforceableDraft = (pr) => + !!pr && pr.state === 'open' && pr.draft === true && + !MAINTAINER_ASSOCIATIONS.has(pr.author_association); + + const openPulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const candidates = openPulls.filter(isEnforceableDraft); + core.info('Sweep found ' + candidates.length + ' non-maintainer draft PR(s) of ' + openPulls.length + ' open.'); + + const failures = []; + + for (const candidate of candidates) { + try { + // Re-fetch immediately before mutating: the contributor may have + // marked the PR ready (or it may have closed) since pagination. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: candidate.number, + }); + + if (!isEnforceableDraft(pr)) { + core.info('Skipping PR #' + candidate.number + ' — no longer an open non-maintainer draft.'); + continue; + } + + // Close FIRST so enforcement (the primary action) is never gated + // on the explanatory comment. A closed PR is never revisited by a + // later sweep, so this also prevents duplicate comments. + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + + core.info('Closed draft PR #' + pr.number + ': ' + pr.title); + } catch (err) { + failures.push(candidate.number); + core.warning('Failed to process draft PR #' + candidate.number + ': ' + err.message); + } + } + + if (failures.length > 0) { + core.setFailed('Sweep encountered errors on ' + failures.length + ' draft PR(s): ' + failures.join(', ')); + } diff --git a/.github/workflows/close-draft-prs.yml b/.github/workflows/close-draft-prs.yml index f2d361e89..6f8eda5cf 100644 --- a/.github/workflows/close-draft-prs.yml +++ b/.github/workflows/close-draft-prs.yml @@ -1,7 +1,18 @@ name: Close Draft PRs +# pull_request_target (not pull_request) so the job runs in the base-repo +# context with a write-capable token even for PRs from forks. Without this, +# fork PRs from first-time/external contributors get a read-only GITHUB_TOKEN +# and the close/comment API calls 403 — letting them bypass the auto-close. +# Safe because this job only reads event metadata and never checks out or +# executes PR-supplied code. +# Residual platform limitation: GitHub deliberately does NOT trigger +# pull_request_target for fork branches whose names look like a Git SHA, so a +# contributor could still evade this by naming their head branch like a commit +# hash. Fully closing that gap needs a scheduled base-context sweep (separate +# concern); a draft PR that evades this still cannot merge and still fails the other gates. on: - pull_request: + pull_request_target: types: [opened, reopened, converted_to_draft] concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2494f99..52145edee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,29 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Preview CHANGELOG (non-destructive) + env: + VERSION: ${{ inputs.version }} + run: | + # Non-destructive CHANGELOG preview for the version under test (#759): + # renders the curated section finalize will promote, without writing + # CHANGELOG.md or consuming .changeset fragments. Surfaced in the job + # summary so RC testers see the upcoming release notes. + # + # Render to a file as a standalone command so a non-zero exit (e.g. a + # malformed fragment) fails the step. The default GitHub Linux shell + # is `bash -e` without pipefail, so a `node | tee` pipeline would mask + # a node failure behind tee's exit 0. + PREVIEW_FILE="${RUNNER_TEMP:-/tmp}/changelog-preview.md" + node scripts/changeset/cli.cjs render \ + --version "$VERSION" --date "$(date -u +%F)" --preview > "$PREVIEW_FILE" + { + echo "### CHANGELOG preview for v${VERSION} (not yet promoted)" + echo '' + cat "$PREVIEW_FILE" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + cat "$PREVIEW_FILE" + - name: Commit pre-release version bump env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} diff --git a/CONTEXT.md b/CONTEXT.md index 13357f78c..0a15b14ff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,9 @@ Module owning which skills and agents are written to runtime config directories ### Runtime Artifact Layout Module Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`). Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +### Runtime Install Policy Module +Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/docs/adr/58-runtime-install-policy-module.md b/docs/adr/58-runtime-install-policy-module.md new file mode 100644 index 000000000..5cbe35794 --- /dev/null +++ b/docs/adr/58-runtime-install-policy-module.md @@ -0,0 +1,56 @@ +# Runtime Install Policy Module owns the typed install-plan projection + +- **Status:** Accepted +- **Date:** 2026-06-07 +- **Issue:** #58 + +## Context + +Runtime install logic is currently spread across one-off helper functions. `getGlobalDir(runtime, explicitDir)` in `bin/install.js` switch-dispatches to per-runtime helpers (`getOpencodeGlobalDir`, `getKiloGlobalDir`), and `getAgentsDir` lives separately in `src/core.cts`. These helpers resolve directories at ~11 call sites and are free to drift from the behavior that install and runtime-query paths actually expect, because nothing owns the *composition* of an install decision as a single value. + +Two adjacent seams already exist: + +- **ADR-3660 (Runtime Artifact Layout Module)** owns *where* per-runtime artifacts (commands, agents, skills) are placed. +- **ADR-0009 (Shell Command Projection Module)** owns runtime-aware *command text* rendering (quoting, path style, wrapper prefixes). + +But no ADR owns composing those — placements + command text + per-runtime config intentions — into one unified, typed install-plan projection. That missing seam is why directory/config logic re-derives itself ad hoc at each call site. + +## Decision + +Introduce a **Runtime Install Policy Module** as the seam that, given a runtime and an install context, **projects a pure, typed `InstallPlan` value** describing everything that should happen for that runtime. The projection: + +- composes artifact placements by delegating to the Runtime Artifact Layout Module (ADR-3660), +- composes command text by delegating to the Shell Command Projection Module (ADR-0009), +- declares config *intentions* (which config files need which keys/values for that runtime), +- performs **no filesystem IO** and **no format-specific serialization** while resolving the plan. + +Concrete execution is owned by runtime-specific **adapters** (made explicit as a registry in #60). Adapters consume the `InstallPlan` and perform the effectful work: file mutations, directory creation, and rendering format-specific config (TOML, JSON, Markdown) for their runtime. + +This follows the repository's established pure-policy-projects / thin-adapters-execute pattern (ADR-0001, Dispatch Policy Module): the `InstallPlan` is the narrow waist, resolution stays free of IO, and callers become thin adapters over a stable interface rather than re-deriving directory logic. + +## What stays OUTSIDE the policy module + +To keep the abstraction honest about the filesystem boundary, the following are explicitly **not** the policy module's responsibility and remain in the runtime adapters: + +- Concrete TOML / JSON / Markdown read-modify-write and serialization. +- Merge semantics for pre-existing config files (preserving user keys, ordering, formatting). +- Filesystem effects: directory creation, atomic write/rename, existence/permission checks. +- Any path resolution that requires touching the disk. + +The policy module resolves *intent* as data; adapters turn that intent into bytes on disk. + +## Consequences + +- Install logic becomes testable as pure data: assert the projected `InstallPlan` for a runtime without a filesystem. +- The scattered directory helpers (`getGlobalDir`, `getOpencodeGlobalDir`, `getKiloGlobalDir`, `getAgentsDir`) gain a single projection to migrate onto, retiring or narrowing them (tracked in #56). +- The plan/adapter contract becomes a stability surface that must be held narrow; drift there reintroduces the very divergence this seam removes. +- Rollout is incremental, not big-bang: this ADR establishes the boundary (#58); the explicit Runtime Adapter Registry lands next (#60); legacy helper retirement follows (#56); downstream cleanup in #57. + +## References + +- ADR-0001 — Dispatch Policy Module (pure-policy-projects / thin-adapters-execute precedent). +- ADR-3660 — Runtime Artifact Layout Module (per-runtime artifact placement; delegated to by this projection). +- ADR-0009 — Shell Command Projection Module (runtime-aware command text; delegated to by this projection). +- ADR-0008 — Installer Migration Module (adjacent installer seam). +- `CONTEXT.md` § Glossary — Domain modules and seams (the architecture seam map / glossary this module is registered in). +- Installer-refactor chain: #58 (this ADR) → #60 (explicit adapter registry) → #56 (retire legacy directory helpers) → #57. diff --git a/docs/adr/README.md b/docs/adr/README.md index f59e54ce6..612e4891f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,6 +54,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [456-test-rigor-architecture.md](456-test-rigor-architecture.md) | Test-rigor architecture — deterministic scheduling, antagonistic tier, typed-surface mandate, delete-bad-tests policy | Accepted | | [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed | | [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed | +| [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted | ## Seam map diff --git a/docs/branching.md b/docs/branching.md index 2f1edd960..22398239f 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -167,6 +167,7 @@ When `next` has accumulated enough work to ship a new minor/major. - Each fix should also be PR'd to `next` so the next release has it too (or wait for the auto-back-merge after finalize) - Trigger `rc` action to publish RC builds: 1.28.0-rc.1, rc.2, ... + - Each `rc` run also prints a non-destructive preview of the curated `## [X.Y.0]` CHANGELOG section in the Actions job summary — rendered from the `.changeset/` fragments without consuming them — so you can review the upcoming release notes during RC testing. 4. When stable: trigger `finalize`. - Publishes to npm @latest diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 283120679..92fc65af8 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -42,6 +42,7 @@ function parseArgs(argv) { installCommand: `npx ${packageName}@latest`, json: false, allowEmpty: false, + preview: false, }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -62,6 +63,7 @@ function parseArgs(argv) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } if (a === '--allow-empty') { opts.allowEmpty = true; continue; } + if (a === '--preview') { opts.preview = true; continue; } if ( a === '--repo' || a === '--version' || @@ -133,6 +135,19 @@ function assembleChangelog(lead, releaseBlock) { ].join('\n'); } +// Insert a "_No notable changes._" placeholder after the dated release heading +// of an otherwise-empty release block. serializeChangelog with no sections +// yields just "## [v] - d\n"; we expand the trailing newline into a blank line +// + placeholder + blank line so parseChangelog still sees the dated heading +// first and the output is human-readable. Shared by the --allow-empty and +// --preview zero-fragment paths so they can never drift. +function injectEmptyPlaceholder(headerOnlyBlock) { + return headerOnlyBlock.replace( + /^(##\s+\[[^\]]+\][^\n]*)\n+/, + '$1\n\n_No notable changes._\n\n', + ); +} + function cmdRender(opts) { const repo = path.resolve(opts.repo); const changesetDir = path.join(repo, '.changeset'); @@ -156,6 +171,38 @@ function cmdRender(opts) { // 2. Read priorText once; reuse in all subsequent branches. const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + // Preview mode (#759): render the dated release section WITHOUT writing + // CHANGELOG.md and WITHOUT consuming .changeset fragments. Used by the rc + // release job to surface the curated notes for the version under test while + // leaving the fragment set intact for the eventual finalize render. + if (opts.preview) { + // priorChangelog is intentionally null: a preview shows ONLY the new dated + // section for the version under test, not the full file history. + // serializeChangelog appends priorChangelog verbatim, so passing the prior + // text here would dump every past release into the rc job summary. + const ir = renderChangelog({ + fragments, + version: opts.version, + date: opts.date, + priorChangelog: null, + }); + let releaseBlock = serializeChangelog(ir); + if (fragments.length === 0) { + // Mirror --allow-empty: a no-fragment release still shows a dated heading + // with a placeholder rather than an empty block. + releaseBlock = injectEmptyPlaceholder(releaseBlock); + } + return { + exitCode: 0, + report: { + consumed: 0, + failures: [], + preview: releaseBlock, + fragmentCount: fragments.length, + }, + }; + } + // 3. FIX 1: idempotency guard — if the version is already promoted (a dated // release heading for this version already exists in CHANGELOG), split on // whether fragments are still present: @@ -201,15 +248,7 @@ function cmdRender(opts) { priorChangelog: prior || null, }); const headerOnlyBlock = serializeChangelog(ir); - // Insert placeholder after the release header line. - // serializeChangelog with no sections yields just "## [v] - d\n" (single - // trailing newline, no blank line). We replace that trailing newline with - // a blank line + placeholder + blank line so parseChangelog still sees the - // dated heading first and the file is human-readable. - const releaseBlock = headerOnlyBlock.replace( - /^(##\s+\[[^\]]+\][^\n]*)\n+/, - '$1\n\n_No notable changes._\n\n', - ); + const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock); // FIX 2: use shared assembleChangelog helper. const out = assembleChangelog(lead, releaseBlock); fs.writeFileSync(changelogPath, out); @@ -460,7 +499,8 @@ function cmdGithubReleaseNotes(opts) { function usage() { return [ 'usage:', - ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--json]', + ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--preview] [--json]', + ' --preview renders the dated section to stdout without writing CHANGELOG.md or consuming fragments.', ' changeset/cli.cjs github-release-notes --repo --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]', ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo ] [--json]', ' Extracts changelog entries strictly after --from (exclusive) and up to', @@ -525,6 +565,9 @@ function main() { const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); if (opts.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else if (opts.cmd === 'render' && opts.preview) { + // render --preview: emit the rendered section verbatim (no mutation occurred). + process.stdout.write(report.preview); } else if (opts.cmd === 'github-release-notes' && report.body) { process.stdout.write(report.body); } else { diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 03ed3b5e5..72b80c91e 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -1,7 +1,7 @@ 'use strict'; process.env.GSD_TEST_MODE = '1'; -const { test, describe, before, after } = require('node:test'); +const { test, describe, before, after, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -36,6 +36,11 @@ function runRender(args = []) { }; } +function runRenderRaw(args = []) { + const r = cp.spawnSync(process.execPath, [SCRIPT, 'render', '--repo', tmp, ...args], { encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-changeset-')); }); after(() => { cleanup(tmp); }); @@ -928,3 +933,107 @@ describe('changeset cli render --allow-empty', () => { } }); }); + +// --------------------------------------------------------------------------- +// GROUP D — render --preview (#759) +// --------------------------------------------------------------------------- + +describe('changeset cli render --preview (#759)', () => { + // Helper: reset the shared tmp dir to a clean state for preview tests. + function resetTmp() { + // Remove CHANGELOG.md if present. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + if (fs.existsSync(changelogPath)) { + fs.unlinkSync(changelogPath); + } + // Remove any leftover .changeset/*.md fragments. + const changesetDir = path.join(tmp, '.changeset'); + if (fs.existsSync(changesetDir)) { + for (const f of fs.readdirSync(changesetDir)) { + if (f.endsWith('.md') && f !== 'README.md') { + fs.unlinkSync(path.join(changesetDir, f)); + } + } + } + } + + // Reset state before each preview test so a new test added to this group + // can never inherit a CHANGELOG.md or fragments left by the previous one. + beforeEach(resetTmp); + + test('render --preview prints the section to stdout and mutates nothing', () => { + writeFragment('preview-frag-one', 'Added', 900, 'preview-added-feature'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-one.md'); + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain ## [9.9.0]; got: ${r.stdout}`); + assert.ok(r.stdout.includes('### Added'), `stdout must contain ### Added; got: ${r.stdout}`); + assert.ok(r.stdout.includes('preview-added-feature'), `stdout must contain fragment body; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by --preview'); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview'); + }); + + test('render --preview with zero fragments emits the placeholder and writes nothing', () => { + // No fragments written — zero-fragment scenario. + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## ['), `stdout must contain a release heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('_No notable changes._'), `stdout must contain placeholder; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by zero-fragment --preview'); + }); + + test('render --preview --json returns preview text in report with consumed 0', () => { + writeFragment('preview-frag-two', 'Fixed', 901, 'preview-fixed-bug'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-two.md'); + // runRender appends --json automatically. + const r = runRender(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.report, 'report must be parseable JSON'); + assert.strictEqual(r.report.consumed, 0, 'consumed must be 0 for preview'); + assert.strictEqual(r.report.fragmentCount, 1, 'fragmentCount must be 1'); + assert.ok(typeof r.report.preview === 'string', 'report.preview must be a string'); + assert.ok(r.report.preview.includes('## [9.9.0]'), `report.preview must contain ## [9.9.0]; got: ${r.report.preview}`); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview --json'); + }); + + test('render --preview with an existing CHANGELOG.md leaves it byte-identical and shows only the new section', () => { + // Seed an existing CHANGELOG with a prior dated release. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + const existing = + '# Changelog\n\n## [1.0.0] - 2020-01-01\n\n### Added\n\n- old prior feature (#1)\n'; + fs.writeFileSync(changelogPath, existing); + writeFragment('preview-frag-three', 'Added', 902, 'brand-new-thing'); + const before = fs.readFileSync(changelogPath, 'utf8'); + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + // Output shows the new section... + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain new heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('brand-new-thing'), `stdout must contain new fragment body; got: ${r.stdout}`); + // ...and NOT the prior release history (preview is the new section only). + assert.ok(!r.stdout.includes('## [1.0.0]'), `preview must NOT include prior releases; got: ${r.stdout}`); + assert.ok(!r.stdout.includes('old prior feature'), `preview must NOT include prior bullets; got: ${r.stdout}`); + + // Existing CHANGELOG.md must be byte-identical — preview mutates nothing. + assert.strictEqual( + fs.readFileSync(changelogPath, 'utf8'), + before, + 'CHANGELOG.md must be byte-identical after --preview', + ); + }); +}); diff --git a/tests/workflow-maintainer-skip.test.cjs b/tests/workflow-maintainer-skip.test.cjs index 64233d25c..3f91a6c6d 100644 --- a/tests/workflow-maintainer-skip.test.cjs +++ b/tests/workflow-maintainer-skip.test.cjs @@ -29,9 +29,53 @@ describe('PR policy workflow maintainer carve-outs', () => { assertMaintainerSkip(workflow); }); + test('draft PR auto-close triggers on pull_request_target so fork PRs cannot bypass it', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs.yml'); + + // A bare `pull_request` trigger hands fork PRs (how first-time/external + // contributors contribute) a read-only GITHUB_TOKEN, so the close/comment + // API calls 403 and the draft PR survives — bypassing the auto-close. + // `pull_request_target` runs in the base-repo context with a write-capable + // token. Guard against a regression back to the bypassable trigger. + assert.match(workflow, /^\s*pull_request_target:/m); + assert.doesNotMatch(workflow, /^\s*pull_request:\s*$/m); + }); + test('PR target validator does not run for maintainer-authored PRs', () => { const workflow = readWorkflow('.github/workflows/pr-target-validator.yml'); assertMaintainerSkip(workflow); }); + + test('draft PR sweep enforces the same policy as the event-driven close', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs-sweep.yml'); + + // Timer-driven in base-repo context, plus a manual dispatch for testing. + // It must NOT be a fork-triggered event (no pull_request / pull_request_target trigger). + assert.match(workflow, /schedule:/); + assert.match(workflow, /cron:\s*'0 \*\/6 \* \* \*'/); + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /^\s*pull_request(_target)?:/m); + + // Write-capable token (needed to close PRs from base context). Tolerant of + // intervening blank lines or additional permission keys. + assert.match(workflow, /permissions:\s+pull-requests:\s*write/); + + // Identical maintainer carve-out to close-draft-prs.yml — a Set membership + // test over author_association, negated (no github.event.pull_request in a + // scheduled run). + assert.match(workflow, /new Set\(\['OWNER', 'MEMBER', 'COLLABORATOR'\]\)/); + assert.match(workflow, /!MAINTAINER_ASSOCIATIONS\.has\([^)]*\.author_association\)/); + + // Paginates over open PRs and filters to drafts. + assert.match(workflow, /github\.paginate\(github\.rest\.pulls\.list/); + assert.match(workflow, /state:\s*'open'/); + assert.match(workflow, /pr\.draft === true/); + + // Same user-facing policy message as close-draft-prs.yml (locks the core + // content so the sweep cannot silently drift to a weaker message). + assert.match(workflow, /## Draft PRs are not accepted/); + assert.match(workflow, /npm run test:coverage/); + assert.match(workflow, /CONTRIBUTING\.md#pull-request-guidelines/); + }); });