From 09bbbe96993ef3197f08b9d272cf0d877fa376d3 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Thu, 4 Jun 2026 10:16:23 +0200 Subject: [PATCH] chore: standardize npm release workflow --- .github/workflows/ci.yml | 17 ++++ .github/workflows/release.yml | 158 +++++++++++++++------------------- VERSION | 1 + package.json | 4 +- scripts/version-sync.mjs | 121 ++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 90 deletions(-) create mode 100644 VERSION create mode 100644 scripts/version-sync.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 727ea65..702ab7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,23 @@ jobs: - run: bun run lint - run: bun run format:check + version-sync: + name: Version sync + if: >- + always() + && (github.event_name != 'pull_request' + || needs.trust-check.outputs.trusted == 'true') + needs: [trust-check] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22.21.1" + - run: node scripts/version-sync.mjs check + test: name: Test if: >- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 482fd53..4dbbe4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,59 +1,67 @@ name: Release -env: - NODE_VERSION: "22" - NPM_VERSION: "11.11.1" - on: + push: + branches: [main] + paths: [VERSION] workflow_dispatch: inputs: - tag: - description: Existing git tag to release, e.g. v0.0.1 - required: true - type: string publish_to_npm: - description: Publish the package to npm after creating the GitHub release + description: Publish the current VERSION to npm after verification required: true default: false type: boolean concurrency: - group: release-${{ inputs.tag }} + group: release-${{ github.repository }}-${{ github.ref }} cancel-in-progress: false +permissions: + contents: read + jobs: + release-ref: + name: Release ref + if: >- + github.event_name == 'workflow_dispatch' + && inputs.publish_to_npm + && github.ref != 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - run: | + echo "::error::Manual npm publishing must run from main." + exit 1 + + preflight: + name: Preflight + if: >- + github.event_name != 'workflow_dispatch' + || inputs.publish_to_npm != true + || github.ref == 'refs/heads/main' + uses: stella/.github/.github/workflows/npm-version-preflight.yml@1372ecc2728373e324db00eb0c99825a852e60a9 + with: + package-files: package.json + publish-to-npm: >- + ${{ github.event_name == 'push' + || (inputs.publish_to_npm && github.ref == 'refs/heads/main') }} + verify: - name: Verify release inputs + name: Verify + needs: preflight + if: needs.preflight.outputs.already-released != 'true' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: refs/tags/${{ inputs.tag }} - fetch-depth: 0 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: ${{ env.NODE_VERSION }} - + node-version: "22.21.1" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - - name: Validate tag and package version - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - git rev-parse --verify "refs/tags/${RELEASE_TAG}" >/dev/null - - PKG_VERSION="$(node -p "require('./package.json').version")" - TAG_VERSION="${RELEASE_TAG#v}" - if [[ "$PKG_VERSION" != "$TAG_VERSION" ]]; then - echo "::error::Tag version (${TAG_VERSION}) does not match package.json version (${PKG_VERSION})." - exit 1 - fi - - run: bun install --frozen-lockfile + - run: bun run version:check - run: bun run lint - run: bun test - run: bun run typecheck @@ -62,82 +70,54 @@ jobs: - run: ORACLE_SAMPLES=500 bun run oracle - run: npm pack --json --ignore-scripts --dry-run - publish: - name: Publish - needs: [verify] + pack: + name: Pack + needs: [preflight, verify] + if: needs.preflight.outputs.already-released != 'true' runs-on: ubuntu-latest permissions: - contents: write - id-token: write + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: refs/tags/${{ inputs.tag }} - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: ${{ env.NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Install npm for release publishing - run: npm install --global "npm@${{ env.NPM_VERSION }}" - + node-version: "22.21.1" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile - run: bun run build - name: Pack release tarball - id: pack shell: bash run: | set -euo pipefail - mkdir -p release-artifacts - - pack_json="$(npm pack --json --ignore-scripts --pack-destination release-artifacts)" - tarball_file="$(echo "$pack_json" | jq -r '.[0].filename')" - sha256sum "release-artifacts/$tarball_file" > release-artifacts/SHA256SUMS - - echo "tarball=release-artifacts/$tarball_file" >> "$GITHUB_OUTPUT" - - - name: Create or update GitHub release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - - assets=( - "${{ steps.pack.outputs.tarball }}" - release-artifacts/SHA256SUMS + npm pack --json --ignore-scripts --pack-destination release-artifacts + ( + cd release-artifacts + sha256sum -- ./*.tgz > SHA256SUMS ) - prerelease_flag=() - if [[ "$RELEASE_TAG" == *-* ]]; then - prerelease_flag+=(--prerelease) - fi - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber - else - gh release create "$RELEASE_TAG" "${assets[@]}" \ - "${prerelease_flag[@]}" \ - --title "$RELEASE_TAG" - fi - - - name: Publish to npm - if: inputs.publish_to_npm - uses: stella/.github/.github/actions/npm-publish-hardened@96b8912e0786548c4e62046ce3965769991dd0e0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - tarball: ${{ steps.pack.outputs.tarball }} - - update-changelog: - name: Update CHANGELOG - needs: publish - if: inputs.publish_to_npm - uses: stella/.github/.github/workflows/changelog-update.yml@314d81ed84537155fb17d57ebdf227aeaca4f907 + name: release-artifacts + path: release-artifacts + if-no-files-found: error + + finalize: + name: Finalize + needs: [preflight, pack] + if: >- + needs.preflight.outputs.already-released != 'true' + && (github.event_name == 'push' + || (inputs.publish_to_npm && github.ref == 'refs/heads/main')) + uses: stella/.github/.github/workflows/npm-version-finalize.yml@1372ecc2728373e324db00eb0c99825a852e60a9 with: - tag: ${{ inputs.tag }} + package-files: package.json + publish-to-npm: >- + ${{ github.event_name == 'push' + || (inputs.publish_to_npm && github.ref == 'refs/heads/main') }} permissions: contents: write + id-token: write pull-requests: write + secrets: inherit diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/package.json b/package.json index 84a9d04..ba15a9c 100644 --- a/package.json +++ b/package.json @@ -917,7 +917,9 @@ "format:check": "oxfmt --check . \"!.ai/**\" \"!.agents/**\" \"!.claude/**\" \"!AGENTS.md\" \"!CLAUDE.md\" \"!GEMINI.md\"", "typecheck": "tsc -p tsconfig.json", "sync-ai:check": "bash scripts/sync-ai-skills.sh --check", - "lint:fix": "bun --bun oxlint -c oxlint.config.ts --type-aware --fix ." + "lint:fix": "bun --bun oxlint -c oxlint.config.ts --type-aware --fix .", + "version:check": "node scripts/version-sync.mjs check", + "version:sync": "node scripts/version-sync.mjs sync" }, "devDependencies": { "@brazilian-utils/brazilian-utils": "^2.3.0", diff --git a/scripts/version-sync.mjs b/scripts/version-sync.mjs new file mode 100644 index 0000000..cb998c9 --- /dev/null +++ b/scripts/version-sync.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../", import.meta.url)); +const VERSION_PATTERN = + /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?$/; + +const repoPath = (...segments) => + path.join(ROOT, ...segments); + +const readJson = (filePath) => + JSON.parse(fs.readFileSync(filePath, "utf8")); + +const writeJson = (filePath, value) => { + fs.writeFileSync( + filePath, + `${JSON.stringify(value, null, 2)}\n`, + ); +}; + +const readVersion = () => + fs.readFileSync(repoPath("VERSION"), "utf8").trim(); + +const writeVersion = (version) => { + fs.writeFileSync(repoPath("VERSION"), `${version}\n`); +}; + +const parseArgs = () => { + const [command, ...rest] = process.argv.slice(2); + const args = new Map(); + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (token === "--version" || token === "--tag") { + const value = rest[index + 1]; + if (value == null) { + throw new Error(`Missing value for ${token}`); + } + args.set(token.slice(2), value); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return { command, args }; +}; + +const expectedVersion = (args) => { + const tag = args.get("tag"); + const version = + args.get("version") ?? + (tag ? tag.replace(/^v/, "") : readVersion()); + if (!VERSION_PATTERN.test(version)) { + throw new Error( + `Expected 1.2.3, 1.2.3-rc.1, 1.2.3-beta.1, or 1.2.3-alpha.1; got '${version}'`, + ); + } + return version; +}; + +const syncVersion = (version) => { + const packageJsonPath = repoPath("package.json"); + const manifest = readJson(packageJsonPath); + writeVersion(version); + manifest.version = version; + writeJson(packageJsonPath, manifest); +}; + +const checkVersion = (version) => { + const packageJsonPath = repoPath("package.json"); + const manifest = readJson(packageJsonPath); + const mismatches = []; + + if (readVersion() !== version) { + mismatches.push( + `${repoPath("VERSION")}: expected ${version}`, + ); + } + if (manifest.version !== version) { + mismatches.push( + `${packageJsonPath}: version=${manifest.version}; expected ${version}`, + ); + } + + if (mismatches.length === 0) { + return; + } + + console.error("Version drift detected:"); + for (const mismatch of mismatches) { + console.error(`- ${mismatch}`); + } + process.exit(1); +}; + +const main = () => { + const { command, args } = parseArgs(); + + if (command !== "sync" && command !== "check") { + console.error( + "Usage: node scripts/version-sync.mjs [--version ] [--tag ]", + ); + process.exit(1); + } + + const version = expectedVersion(args); + + if (command === "sync") { + syncVersion(version); + return; + } + + checkVersion(version); +}; + +main();