diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 559fa38..8759dcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,48 @@ +# Release — versioning and publishing to public npm through the standard +# Changesets flow, the same shape linq-kit uses. +# +# Two modes, chosen automatically by changesets/action@v1 on every push to +# main: +# +# 1. Unconsumed changeset files exist in `.changeset/*.md` → the action +# opens (or refreshes) a "chore: release dds" PR that bumps the fixed +# group's versions and deletes the consumed changesets. Merging that PR +# is the release decision. +# +# 2. No unconsumed changesets (the release PR was just merged, or a +# version was bumped by hand) → the action runs `pnpm release`, which +# is `changeset publish`: every package whose version is not yet on +# npm is published, and a git tag per package is pushed. Versions +# already on npm are skipped, so re-runs are safe. +# +# The publish only happens after the full verification gate below — the +# same gates CI runs — so a red main never reaches npm. Publishing uses the +# repository `NPM_TOKEN` secret with npm provenance (`id-token: write`). +# +# Before 2026-09-03 this workflow ran only on a hand-pushed `dds-v*` tag; +# two release commits (0.4.0, 0.4.1) sat on main for days without a tag and +# npm stayed at 0.3.0. The tag is no longer the trigger. + name: Release public packages on: push: - tags: - - "dds-v*" + branches: [main] + +concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: - contents: read + contents: write + pull-requests: write id-token: write jobs: - publish: + release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: @@ -36,7 +65,25 @@ jobs: - run: pnpm run verify:site-kit:ui - run: pnpm run verify:site-kit:browser - run: pnpm run verify:site-kit:release - - run: pnpm release + - name: Create the release PR or publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: pnpm release + version: pnpm version-packages + commit: "chore: release dds" + title: "chore: release dds" env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true + - name: Summarise what was published + if: steps.changesets.outputs.published == 'true' + env: + PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} + run: | + { + echo "### Published to npm" + echo "" + echo "$PUBLISHED" | jq -r '.[] | "- `\(.name)@\(.version)`"' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.ko.md b/README.ko.md index 440c9a5..829717f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -48,9 +48,13 @@ pnpm 워크스페이스. `pnpm install` 후: `verify:site-kit:release` — 정확한 로케일·카탈로그 계약, 공통 UI 동작, 다국어 검색 문서, 패키징·신규 소비자·publish dry-run 게이트 -다섯 공개 패키지는 Changesets의 lockstep 그룹으로 릴리스한다. 방향성 -아이콘에는 `dds-icon--directional`을 사용하며 mirror/유지 목록은 -`@devslab/dds-icons/direction-policy.json`으로 함께 배포한다. +다섯 공개 패키지는 Changesets의 lockstep 그룹으로 릴리스하며, 릴리스는 +자동이다: `main`에 푸시될 때마다 `.github/workflows/release.yml`이 전체 검증 +게이트를 통과한 뒤 `changesets/action`이 — 대기 중인 changeset 파일이 있으면 +"chore: release dds" 버전 PR을 열고, 그 PR이 방금 머지됐으면 `main`에는 있지만 +npm에는 아직 없는 버전을 배포한다. 버전 PR 머지가 곧 릴리스 결정이고, 손으로 +태그를 푸시하지 않는다. 방향성 아이콘에는 `dds-icon--directional`을 사용하며 +mirror/유지 목록은 `@devslab/dds-icons/direction-policy.json`으로 함께 배포한다. 배포 가능한 DDS 패키지는 모두 공개 npm의 `@devslab/*`를 사용하며 DevsLab Source-Available License 1.0을 따른다. compatibility canary는 비공개·미배포로 diff --git a/README.md b/README.md index b939c2f..9df7074 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,14 @@ pnpm workspace. `pnpm install`, then: `verify:site-kit:release` — exact locale/catalog contracts, shared UI behavior, localized discovery documents, pack, fresh-consumer and publish dry-run gates -The five public packages release in lockstep through Changesets. Directional -icons use `dds-icon--directional`; the authoritative mirror/keep lists ship as -`@devslab/dds-icons/direction-policy.json`. +The five public packages release in lockstep through Changesets, and the release +is automatic: every push to `main` runs `.github/workflows/release.yml`, which +passes the full verification gate and then lets `changesets/action` either open +the "chore: release dds" version PR (when changeset files are waiting) or publish +whatever version is on `main` but not yet on npm (when that PR has just merged). +Merging the version PR is the release decision; no tag is pushed by hand. +Directional icons use `dds-icon--directional`; the authoritative mirror/keep +lists ship as `@devslab/dds-icons/direction-policy.json`. All distributable DDS packages use `@devslab/*` on the public npm registry under the DevsLab Source-Available License 1.0. The compatibility canary remains private and diff --git a/scripts/verify-site-kit-release.mjs b/scripts/verify-site-kit-release.mjs index d0ba179..1995851 100644 --- a/scripts/verify-site-kit-release.mjs +++ b/scripts/verify-site-kit-release.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; const workspace = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(?:([A-Za-z]:))/, "$1")); @@ -29,6 +29,24 @@ const runPnpm = (args, cwd) => { if (result.status !== 0) throw new Error(`pnpm ${args.join(" ")} failed\n${result.stdout}\n${result.stderr}`); return result.stdout; }; +// `npm publish --dry-run` still asks the registry whether the version exists and +// refuses one that is already published. On main right after a release that is +// the normal state, not a defect — the pack, the manifest and the fresh-consumer +// install are what this gate checks — so "already published" counts as passed. +const publishDryRun = (tarball, cwd) => { + const result = spawnSync(npmCli, [...npmPrefix, "publish", tarball, "--dry-run", "--json", "--ignore-scripts"], { + cwd, + encoding: "utf8", + env: { ...process.env, NPM_CONFIG_CACHE: join(temp, ".npm-cache") }, + }); + if (result.status === 0) return; + const output = `${result.stdout}\n${result.stderr}`; + if (/cannot publish over the previously published versions/i.test(output)) { + console.log(`${basename(tarball)} is already on npm; publish dry-run skipped`); + return; + } + throw new Error(`publish ${basename(tarball)} --dry-run failed\n${output}`); +}; try { const packageNames = ["dds-tokens", "dds-css", "dds-icons", "dds-solid", "site-kit"]; @@ -45,7 +63,7 @@ try { const packageRoot = join(workspace, "packages", "site-kit"); const bundle = await readFile(join(packageRoot, "dist", "solid.js"), "utf8"); assert.match(bundle, /from\s+["']@devslab\/dds-solid["']/, "site-kit must externalize dds-solid"); - run(["publish", siteKitTarball, "--dry-run", "--json", "--ignore-scripts"], packageRoot); + publishDryRun(siteKitTarball, packageRoot); await writeFile(join(temp, "package.json"), JSON.stringify({ private: true, type: "module" }), "utf8"); run(["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], temp); const installedRoot = join(temp, "node_modules", "@devslab", "site-kit"); diff --git a/scripts/verify-solid-release.mjs b/scripts/verify-solid-release.mjs index e06bfbd..f1029e9 100644 --- a/scripts/verify-solid-release.mjs +++ b/scripts/verify-solid-release.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const workspace = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(?:([A-Za-z]:))/, "$1")); @@ -28,6 +28,24 @@ const runPnpm = (args, cwd) => { if (result.status !== 0) throw new Error(`pnpm ${args.join(" ")} failed\n${result.stdout}\n${result.stderr}`); return result.stdout; }; +// `npm publish --dry-run` still asks the registry whether the version exists and +// refuses one that is already published. On main right after a release that is +// the normal state, not a defect — the pack, the manifest and the fresh-consumer +// install are what this gate checks — so "already published" counts as passed. +const publishDryRun = (tarball, cwd) => { + const result = spawnSync(npmCli, [...npmPrefix, "publish", tarball, "--dry-run", "--json", "--ignore-scripts"], { + cwd, + encoding: "utf8", + env: { ...process.env, NPM_CONFIG_CACHE: join(temp, ".npm-cache") }, + }); + if (result.status === 0) return; + const output = `${result.stdout}\n${result.stderr}`; + if (/cannot publish over the previously published versions/i.test(output)) { + console.log(`${basename(tarball)} is already on npm; publish dry-run skipped`); + return; + } + throw new Error(`publish ${basename(tarball)} --dry-run failed\n${output}`); +}; const runNode = (args, cwd) => { const result = spawnSync(process.execPath, args, { cwd, encoding: "utf8" }); if (result.status !== 0) throw new Error(`node ${args.join(" ")} failed\n${result.stdout}\n${result.stderr}`); @@ -51,7 +69,7 @@ try { const source = await readFile(join(packageRoot, bundle), "utf8"); assert.match(source, /from\s+["']@devslab\/dds-icons["']/, `${bundle} must externalize dds-icons`); } - run(["publish", solidTarball, "--dry-run", "--json", "--ignore-scripts"], packageRoot); + publishDryRun(solidTarball, packageRoot); await writeFile(join(temp, "package.json"), JSON.stringify({ private: true, type: "module" }), "utf8"); run(["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs, "solid-js@1.9.15", "jsdom@30.0.1"], temp); const installedRoot = join(temp, "node_modules", "@devslab", "dds-solid");