Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
This commit is contained in:
112
.github/workflows/close-draft-prs-sweep.yml
vendored
Normal file
112
.github/workflows/close-draft-prs-sweep.yml
vendored
Normal file
@@ -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(', '));
|
||||
}
|
||||
13
.github/workflows/close-draft-prs.yml
vendored
13
.github/workflows/close-draft-prs.yml
vendored
@@ -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:
|
||||
|
||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
56
docs/adr/58-runtime-install-policy-module.md
Normal file
56
docs/adr/58-runtime-install-policy-module.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <dir> --version V --date D [--allow-empty] [--json]',
|
||||
' changeset/cli.cjs render --repo <dir> --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 <dir> --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 <dir>] [--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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user