From 2a92e90f9a685e1749432a5bc17371f193b6d1f0 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Tue, 26 May 2026 15:58:35 +0800 Subject: [PATCH] ci: add npm release workflow --- .github/workflows/publish.yml | 150 ++++++++++++++++++++++++++++++++++ README.md | 10 +-- src/cli/args.ts | 2 +- 3 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..481feba --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,150 @@ +name: Release + +on: + push: + tags: + - "v*" + +concurrency: + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + # npm trusted publishing + provenance requires a GitHub-hosted runner. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + check-latest: true + registry-url: https://registry.npmjs.org + cache: npm + + - run: npm ci + + - name: Validate package metadata for trusted publishing + run: | + node - <<'NODE' + const { readFileSync } = require("node:fs"); + + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const expectedName = "github-sane-defaults"; + const expectedRepoUrl = "https://github.com/dutifuldev/github-sane-defaults"; + const expectedBin = "dist/src/cli/main.js"; + + const normalizeRepoUrl = (value) => + String(value ?? "") + .trim() + .replace(/^git\+/, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); + + const actualRepoUrl = normalizeRepoUrl(pkg?.repository?.url); + const expectedRepoUrlNormalized = normalizeRepoUrl(expectedRepoUrl); + const errors = []; + + if (pkg?.name !== expectedName) { + errors.push(`package.json name must be ${expectedName}; found ${pkg?.name ?? ""}`); + } + if (pkg?.private === true) { + errors.push("package.json must not be private for npm publishing."); + } + if (actualRepoUrl !== expectedRepoUrlNormalized) { + errors.push( + `package.json repository.url must resolve to ${expectedRepoUrlNormalized}; found ${actualRepoUrl || ""}` + ); + } + if (pkg?.publishConfig?.access !== "public") { + errors.push("package.json publishConfig.access must be public."); + } + if (pkg?.bin?.["github-sane-defaults"] !== expectedBin) { + errors.push( + `package.json bin.github-sane-defaults must be ${expectedBin}; found ${pkg?.bin?.["github-sane-defaults"] ?? ""}` + ); + } + + if (errors.length > 0) { + for (const err of errors) { + console.error(err); + } + process.exit(1); + } + + console.log("Package metadata validated."); + NODE + + - name: Validate release tag + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + git fetch --no-tags origin main --depth=1 + + node - <<'NODE' + const { execFileSync } = require("node:child_process"); + const { readFileSync } = require("node:fs"); + + const releaseTag = process.env.RELEASE_TAG ?? ""; + const releaseSha = process.env.RELEASE_SHA ?? ""; + const semverTag = /^v\d+\.\d+\.\d+$/; + + if (!semverTag.test(releaseTag)) { + console.error(`Release tags must match vX.Y.Z; received ${releaseTag || ""}.`); + process.exit(1); + } + + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const expectedTag = `v${pkg.version}`; + + if (releaseTag !== expectedTag) { + console.error( + `Release tag ${releaseTag} does not match package.json version ${pkg.version}; expected ${expectedTag}.` + ); + process.exit(1); + } + + try { + execFileSync("git", ["merge-base", "--is-ancestor", releaseSha, "origin/main"], { + stdio: "ignore" + }); + } catch { + console.error(`Tagged commit ${releaseSha} is not contained in origin/main.`); + process.exit(1); + } + + console.log( + `Release tag ${releaseTag} matches package.json and points to a commit on origin/main.` + ); + NODE + + - name: Ensure version is not already published + run: | + set -euo pipefail + package_version="$(node -p "require('./package.json').version")" + published_version="$(npm view "github-sane-defaults@$package_version" version 2>/dev/null || true)" + + if [ "$published_version" = "$package_version" ]; then + echo "github-sane-defaults@$package_version is already published on npm." + exit 1 + fi + + echo "Publishing github-sane-defaults@$package_version" + + - run: npm run check + - run: npm run dry + - run: npm run mutate + - run: npx -y @simpledoc/simpledoc check + - run: git diff --check + - run: npm publish --access public --provenance diff --git a/README.md b/README.md index 0a76226..9b31b5c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ npm install -g github-sane-defaults Or run without installing: ```sh -npx github-sane-defaults plan dutifuldev/scratch +npx github-sane-defaults plan example-org/example-repo ``` ## Authentication @@ -31,22 +31,22 @@ repositories. Preview changes for one repository: ```sh -github-sane-defaults plan dutifuldev/scratch +github-sane-defaults plan example-org/example-repo ``` Apply changes to one repository: ```sh -github-sane-defaults apply dutifuldev/scratch +github-sane-defaults apply example-org/example-repo ``` Apply changes to every non-archived repository in an organization: ```sh -github-sane-defaults apply dutifuldev --all +github-sane-defaults apply example-org --all ``` -The legacy `--org dutifuldev --repo scratch` form is still accepted. +The legacy `--org example-org --repo example-repo` form is still accepted. ## Defaults diff --git a/src/cli/args.ts b/src/cli/args.ts index 6a77424..61c6c8d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -138,7 +138,7 @@ function applyPositionalTargets(flags: ParsedFlags): void { function applyOrgTarget(flags: ParsedFlags): void { if (flags.targets.length !== 1 || flags.targets[0]?.includes("/") === true) { - throw new Error("Use an organization name with --all, for example: dutifuldev --all."); + throw new Error("Use an organization name with --all, for example: example-org --all."); } setTargetOrg(flags, flags.targets[0]);