diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3818d08..6f3b657 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ on: - minor - major preview: - description: "Preview mode (skip release and publish)" + description: "Preview: run every check and an npm publish dry run, create nothing" required: false type: boolean default: false @@ -27,20 +27,20 @@ jobs: version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} steps: + # Full history, so the latest version tag can be found. - uses: actions/checkout@v5 with: fetch-depth: 0 - token: ${{ secrets.GH_PAT }} - uses: ./.github/actions/setup - name: Release id: release - run: bun run scripts/release.ts + run: node scripts/release.ts env: BUMP: ${{ inputs.bump }} PREVIEW: ${{ inputs.preview }} - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ github.token }} publish: needs: release @@ -57,4 +57,6 @@ jobs: - uses: ./.github/actions/setup - name: Publish to npm - run: bun run scripts/publish.ts + run: node scripts/publish.ts + env: + VERSION: ${{ needs.release.outputs.version }} diff --git a/package.json b/package.json index 35a30e3..ef9d686 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@donnes/syncode", - "version": "1.1.9", + "version": "0.0.0-development", "private": false, "description": "Sync AI coding agent instructions, skills and configs (Claude Code, Codex, OpenCode, Gemini CLI, Cursor, Copilot) across machines.", "keywords": [ diff --git a/scripts/publish.ts b/scripts/publish.ts index 80e85a1..a8e18fe 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,12 +1,13 @@ -#!/usr/bin/env bun -// Publishes to npm with trusted publishing (GitHub OIDC), so no npm token is -// stored in the repo. Requires npm >= 11.5.1 and the package's trusted -// publisher set to this repo's "Release & Publish" workflow on npmjs.com. +// Publishes the tagged version to npm with trusted publishing (GitHub OIDC), +// so no npm token is stored in the repo. Requires npm >= 11.5.1 and the +// package's trusted publisher set to this repo's publish.yml on npmjs.com. -import { $ } from "bun"; +import { run } from "./run.ts"; -await $`bun run build`; -await $`npm publish --access public --provenance`; +const version = process.env.VERSION; +if (!version) throw new Error("Set VERSION to the version being published."); -const pkg = await Bun.file("package.json").json(); -console.log(`Published ${pkg.name}@${pkg.version}`); +// The repo's package.json carries a placeholder; the tag is the version. +run("npm", ["version", version, "--no-git-tag-version"]); +run("bun", ["run", "build"]); +run("npm", ["publish", "--access", "public", "--provenance"]); diff --git a/scripts/release.ts b/scripts/release.ts index 3947c92..7e8f997 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,42 +1,58 @@ -#!/usr/bin/env bun -// Bumps the version, tags it and creates a GitHub release with generated -// notes. Run by the "Release & Publish" workflow; set PREVIEW=true to only -// print the next version. +// Tags the next version and creates its GitHub release with generated notes. +// Versions come from git tags, not commits: nothing is pushed to main (which +// requires reviewed PRs), so the workflow's own GITHUB_TOKEN is enough. The +// publish job stamps the version into package.json at build time. +// +// With PREVIEW=true it runs every check and an npm publish dry run of the +// next version, but creates nothing. -import { writeFile } from "node:fs/promises"; -import { $ } from "bun"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { run } from "./run.ts"; -const bump = process.env.BUMP; -if (bump !== "patch" && bump !== "minor" && bump !== "major") { - console.error("Set BUMP to patch, minor or major."); - process.exit(1); +type Bump = "patch" | "minor" | "major"; + +function isBump(value: string | undefined): value is Bump { + return value === "patch" || value === "minor" || value === "major"; +} + +function output(command: string, args: string[]): string { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout.trim(); } + +const bump = process.env.BUMP; +if (!isBump(bump)) throw new Error("Set BUMP to patch, minor or major."); const preview = process.env.PREVIEW === "true"; -const pkg = await Bun.file("package.json").json(); -const [major = 0, minor = 0, patch = 0] = String(pkg.version).split(".").map(Number); +const latest = output("git", ["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]); +const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latest); +if (!match) throw new Error(`Unexpected latest tag: ${latest}`); +const [major, minor, patch] = [Number(match[1]), Number(match[2]), Number(match[3])]; const version = { major: `${major + 1}.0.0`, minor: `${major}.${minor + 1}.0`, patch: `${major}.${minor}.${patch + 1}`, }[bump]; const tag = `v${version}`; -console.log(`Bumping ${pkg.version} -> ${version}`); - -await $`bun run check`; - -if (!preview) { - pkg.version = version; - await writeFile("package.json", `${JSON.stringify(pkg, null, "\t")}\n`); - await $`git config user.name "github-actions[bot]"`; - await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; - await $`git add package.json`; - await $`git commit -m ${`release: ${tag}`}`; - await $`git tag ${tag}`; - await $`git push origin HEAD --tags --no-verify`; - await $`gh release create ${tag} --title ${tag} --generate-notes`; +console.log(`${preview ? "Preview: " : ""}${latest} -> ${tag}`); + +run("bun", ["run", "check"]); + +if (preview) { + const original = fs.readFileSync("package.json", "utf8"); + try { + run("npm", ["version", version, "--no-git-tag-version"]); + run("npm", ["publish", "--dry-run", "--access", "public"]); + } finally { + fs.writeFileSync("package.json", original); + } +} else { + const target = process.env.GITHUB_SHA ?? output("git", ["rev-parse", "HEAD"]); + run("gh", ["release", "create", tag, "--target", target, "--title", tag, "--generate-notes"]); } if (process.env.GITHUB_OUTPUT) { - await writeFile(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\n`, { flag: "a" }); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\n`); } diff --git a/scripts/run.ts b/scripts/run.ts new file mode 100644 index 0000000..386214f --- /dev/null +++ b/scripts/run.ts @@ -0,0 +1,9 @@ +import { spawnSync } from "node:child_process"; + +/** Runs a command with inherited output, failing the script if it fails. */ +export function run(command: string, args: string[]) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} exited with ${String(result.status)}`); +} diff --git a/src/update-check.ts b/src/update-check.ts index f209104..e77bc39 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -53,7 +53,15 @@ function isOutdated(current: string, latest: string): boolean { * request timeout, and stays quiet outside interactive terminals. */ export function checkForUpdates(packageName: string, current: string) { - if (!canPrompt() || process.env.CI || process.env.SYNCODE_NO_UPDATE_CHECK) return; + // Local builds carry package.json's 0.0.0-development placeholder. + if ( + current.includes("-") || + !canPrompt() || + process.env.CI || + process.env.SYNCODE_NO_UPDATE_CHECK + ) { + return; + } const cache = readCache(); if (cache && isOutdated(current, cache.latest)) { console.log( diff --git a/tsconfig.json b/tsconfig.json index 1dc9030..65210a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ "allowUnreachableCode": false, "allowUnusedLabels": false }, - "include": ["src", "tests", "*.config.ts", "scripts/check-package.ts"] + "include": ["src", "tests", "scripts", "*.config.ts"] }