diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f958d7995d..91a6d05888 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -46,7 +46,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - name: Set up Node.js uses: actions/setup-node@v6 @@ -96,7 +96,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - name: Set up Node.js uses: actions/setup-node@v6 @@ -164,7 +164,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - name: Set up Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 0033344f08..5378f954eb 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -51,7 +51,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - name: Set up Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index cfbd988edf..f4edd4d884 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -37,7 +37,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: @@ -94,7 +94,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e7015421a..084be46dbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: @@ -251,7 +251,7 @@ jobs: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" @@ -280,7 +280,7 @@ jobs: ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: node-version: "24" @@ -500,7 +500,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: "9" + version: "9.15.9" - uses: actions/setup-node@v6 with: diff --git a/Dockerfile b/Dockerfile index 354ea12a33..9235ea353c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,8 @@ RUN go build -ldflags "-s -w" -o /out/kandev ./cmd/kandev && \ # --------------------------------------------------------------------------- FROM node:24-slim AS web-builder -RUN corepack enable && corepack prepare pnpm@latest --activate +ARG PNPM_VERSION=9.15.9 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate WORKDIR /build/apps diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts new file mode 100644 index 0000000000..70e1ff868d --- /dev/null +++ b/apps/cli/src/release-config.test.ts @@ -0,0 +1,171 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(__dirname, "../../.."); + +function readRepoFile(path: string): string { + return readFileSync(resolve(repoRoot, path), "utf8"); +} + +function workflowFiles(): string[] { + const workflowDir = resolve(repoRoot, ".github/workflows"); + return readdirSync(workflowDir) + .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) + .map((name) => `.github/workflows/${name}`); +} + +function extractDockerPnpmVersion(dockerfile: string): string | undefined { + return dockerfile.match(/^ARG PNPM_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$/m)?.[1]; +} + +function indentation(line: string): number { + return line.search(/\S/); +} + +function isBlankOrComment(line: string): boolean { + const trimmed = line.trim(); + return trimmed.length === 0 || trimmed.startsWith("#"); +} + +function parseVersionLine(line: string): string | undefined { + const match = line.match(/^\s*version:\s*(?:"([^"]+)"|'([^']+)'|([^"'\s#]+))\s*(?:#.*)?$/); + return match?.[1] ?? match?.[2] ?? match?.[3]; +} + +function findVersionInWithBlock( + lines: string[], + start: number, + withIndent: number, +): string | undefined { + for (let index = start; index < lines.length; index += 1) { + const line = lines[index]; + if (isBlankOrComment(line)) { + continue; + } + if (indentation(line) <= withIndent) { + return undefined; + } + + const version = parseVersionLine(line); + if (version !== undefined) { + return version; + } + } + + return undefined; +} + +function findPnpmSetupVersion( + lines: string[], + start: number, + stepIndent: number, +): string | undefined { + for (let index = start; index < lines.length; index += 1) { + const line = lines[index]; + if (isBlankOrComment(line)) { + continue; + } + if (indentation(line) <= stepIndent) { + return undefined; + } + + const withMatch = line.match(/^(\s*)with:\s*(?:#.*)?$/); + if (withMatch !== null) { + return findVersionInWithBlock(lines, index + 1, withMatch[1].length); + } + } + + return undefined; +} + +function matchPnpmSetupUsesLine(line: string): RegExpMatchArray | null { + return line.match(/^(\s*)(?:-\s*)?uses:\s*["']?pnpm\/action-setup@v\d+["']?\s*(?:#.*)?$/); +} + +function findStepIndent(lines: string[], usesLineIndex: number, usesIndent: number): number { + if (lines[usesLineIndex].trimStart().startsWith("- ")) { + return usesIndent; + } + + for (let index = usesLineIndex - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (isBlankOrComment(line)) { + continue; + } + + const lineIndent = indentation(line); + if (lineIndent < usesIndent && line.slice(lineIndent).startsWith("- ")) { + return lineIndent; + } + } + + return Math.max(0, usesIndent - 2); +} + +function extractWorkflowPnpmVersions(workflow: string): Array { + const lines = workflow.split(/\r?\n/); + const versions: Array = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const setupMatch = matchPnpmSetupUsesLine(line); + if (setupMatch !== null) { + const stepIndent = findStepIndent(lines, index, setupMatch[1].length); + versions.push(findPnpmSetupVersion(lines, index + 1, stepIndent)); + } + } + + return versions; +} + +function assertWorkflowPnpmVersions(file: string, expectedVersion: string): number { + const versions = extractWorkflowPnpmVersions(readRepoFile(file)); + for (const version of versions) { + if (version === undefined) { + expect(version, `${file}: pnpm/action-setup step is missing a version pin`).toBeDefined(); + continue; + } + + expect(version, `${file}: pnpm/action-setup version must match Dockerfile PNPM_VERSION`).toBe( + expectedVersion, + ); + } + + return versions.length; +} + +describe("release package manager version", () => { + it("pins pnpm consistently for Docker and GitHub Actions", () => { + const dockerfile = readRepoFile("Dockerfile"); + const dockerPnpmVersion = extractDockerPnpmVersion(dockerfile); + + expect(dockerfile).not.toContain("pnpm@latest"); + expect(dockerPnpmVersion, "Dockerfile: PNPM_VERSION must be pinned").toBeDefined(); + if (dockerPnpmVersion === undefined) { + throw new Error("Dockerfile: PNPM_VERSION must be pinned"); + } + + const workflowSetupCount = workflowFiles().reduce( + (count, file) => count + assertWorkflowPnpmVersions(file, dockerPnpmVersion), + 0, + ); + expect(workflowSetupCount).toBeGreaterThan(0); + }); +}); + +describe("release npm publishing", () => { + it("skips packages that are already published on npm", () => { + const script = readRepoFile("scripts/release/publish-npm.sh"); + + expect(script).toContain('npm view "${pkg}@${VERSION}" version --silent'); + expect(script).toMatch( + /if package_already_published "\$pkg"; then\s+record_already_published "\$pkg"\s+continue\s+fi/, + ); + expect(script).toMatch( + /if package_already_published "kandev"; then\s+record_already_published "kandev"/, + ); + expect(script).toContain("EPUBLISHCONFLICT"); + expect(script).toContain("treated as idempotent success"); + }); +}); diff --git a/scripts/release/publish-npm.sh b/scripts/release/publish-npm.sh index 07728d1534..d59906bcf8 100755 --- a/scripts/release/publish-npm.sh +++ b/scripts/release/publish-npm.sh @@ -33,6 +33,17 @@ yellow(){ printf '\033[33m%s\033[0m' "$*"; } log() { echo " >> $*"; } log_ok() { echo " $(green "ok") $*"; } +package_already_published() { + local pkg="$1" + npm view "${pkg}@${VERSION}" version --silent >/dev/null 2>&1 +} + +record_already_published() { + local pkg="$1" + echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2 + ALREADY_PUBLISHED+=("$pkg") +} + die() { echo "$(red "Error:") $*" >&2 exit 1 @@ -97,6 +108,11 @@ for pkg in "${RUNTIME_PACKAGES[@]}"; do continue fi + if package_already_published "$pkg"; then + record_already_published "$pkg" + continue + fi + log "Publishing $pkg@$VERSION..." # Capture full npm output so we can show the real error on failure rather # than just a generic warning. Distinguish "already published" (idempotent @@ -104,8 +120,7 @@ for pkg in "${RUNTIME_PACKAGES[@]}"; do if output="$(cd "$pkg_dir" && npm publish --access public --provenance 2>&1)"; then log_ok "$pkg@$VERSION published" elif echo "$output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then - echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2 - ALREADY_PUBLISHED+=("$pkg") + record_already_published "$pkg" else echo " $(red "FAIL") Failed to publish $pkg@$VERSION:" >&2 echo "$output" | sed 's/^/ /' >&2 @@ -157,11 +172,12 @@ echo "$(bold "Publishing kandev@$VERSION...")" # Same idempotency handling as the runtime packages: capture output, treat # "already published" as success so partial-failure re-runs converge. # `prepublishOnly` (in package.json) runs `pnpm build` automatically. -if main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance 2>&1)"; then +if package_already_published "kandev"; then + record_already_published "kandev" +elif main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance 2>&1)"; then log_ok "kandev@$VERSION published" elif echo "$main_output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then - echo " $(yellow "skip") kandev@$VERSION already published (treated as idempotent success)" >&2 - ALREADY_PUBLISHED+=("kandev") + record_already_published "kandev" else echo " $(red "FAIL") Failed to publish kandev@$VERSION:" >&2 echo "$main_output" | sed 's/^/ /' >&2 @@ -174,7 +190,7 @@ fi echo echo "$(green "$(bold "All npm packages published successfully!")")" if [[ "${#ALREADY_PUBLISHED[@]}" -gt 0 ]]; then - echo " $(yellow "note") The following runtime packages were already published at $VERSION:" + echo " $(yellow "note") The following npm packages were already published at $VERSION:" for pkg in "${ALREADY_PUBLISHED[@]}"; do echo " - $pkg" done