diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8284e8e..e5513c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, "release/**"] pull_request: workflow_dispatch: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1734c47..67f322d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,12 +22,41 @@ permissions: contents: read jobs: + # Decided once, before either registry publishes: two jobs each reading + # main could disagree if main moved to a new line between them. + # Every release from this branch is a patch of the 0.1 line, published + # under `release-0.1` only: `next` and `latest` belong to main's line, + # and `alpha` is no longer moved (scripts/dist-tag.mjs). + dist-tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + version: ${{ steps.tag.outputs.version }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 26 + + - name: Choose the dist-tag from the version + id: tag + run: | + VERSION=$(node -p "require('./package.json').version") + git fetch --no-tags --depth=1 origin main + MAIN_VERSION=$(git show FETCH_HEAD:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") + TAG=$(node scripts/dist-tag.mjs "$VERSION" "$MAIN_VERSION") + echo "Publishing $VERSION under dist-tag $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Consumed by internal projects that already authenticate to GitHub. github-packages: if: >- github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'github-packages' runs-on: ubuntu-latest + needs: dist-tag environment: release permissions: contents: read @@ -66,23 +95,9 @@ jobs: exit 1 fi - - name: Choose the dist-tag from the version - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; - esac - echo "Publishing $VERSION under dist-tag $TAG" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: pnpm publish --no-git-checks --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + run: pnpm publish --no-git-checks --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://npm.pkg.github.com env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -99,6 +114,7 @@ jobs: github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'npmjs' runs-on: ubuntu-latest + needs: dist-tag environment: release permissions: contents: read @@ -140,20 +156,6 @@ jobs: exit 1 fi - - name: Choose the dist-tag from the version - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; - esac - echo "Publishing $VERSION under dist-tag $TAG" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Confirm the credential is valid for npmjs run: npm whoami --registry https://registry.npmjs.org env: @@ -164,12 +166,12 @@ jobs: # since both pack from the same files allowlist. - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: npm publish --provenance --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: npm publish --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: npm publish --dry-run --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acb7de..b5ef605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Changed + +- This branch, `release/0.1`, maintains the 0.1 line. Its patch releases + publish under the `release-0.1` dist-tag; `alpha`, `next` and `latest` no + longer move from here. + ## [0.1.0-alpha.23] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 603f4cd..fbdf24c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,12 +157,32 @@ consumer and who is doing that migration. ## Releasing -1. Update the version in `package.json` and add a `CHANGELOG.md` entry. -2. Merge to `main` and confirm CI is green, including the external install job. -3. Create a GitHub release tagged `v`. The publish workflow verifies - that the tag matches `package.json` and refuses to publish on a mismatch. -4. The workflow runs in the `release` environment with `packages: write` and - the built-in `GITHUB_TOKEN`. No long-lived credential is stored here. +This branch, `release/0.1`, carries the 0.1 line after `main` moved to 0.2. +It was cut from `v0.1.0-alpha.23`. It takes fixes only: the "Patch" row of +[Versioning](#versioning), with no new components, props or exports. The full +procedure is in `main`'s CONTRIBUTING.md, "Patching an older line". + +1. Branch from `release/0.1` and open the pull request against it. If the bug + is also on `main`, fix it there first and `git cherry-pick -x` the commit; + otherwise say in the pull request that `main` is unaffected. +2. Bump `package.json` to the next `0.1.0-alpha.N` and add a `CHANGELOG.md` + entry. Merge and confirm CI is green, including the external install job. +3. Create the GitHub release on this branch: + + ``` + gh release create v0.1.0-alpha.24 --target release/0.1 --prerelease --latest=false \ + --title v0.1.0-alpha.24 --notes-file notes.md + ``` + + `notes.md` holds the CHANGELOG entry. + + The publish workflow verifies that the tag matches `package.json`, and + publishes under `release-0.1` only (`scripts/dist-tag.mjs` compares the + version with `main`'s), so `next`, `latest` and `alpha` do not move. + It runs in the `release` environment. + +4. Check `npm view @lablup/ui-common dist-tags`, then copy the CHANGELOG + entry into `main`'s `CHANGELOG.md`. ## Pre-public review diff --git a/README.md b/README.md index f096210..676bd8a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # @lablup/ui-common +> **This is the 0.1 maintenance branch.** 0.1 gets fixes only, released as +> patches of the 0.1 line under the `release-0.1` dist-tag. New work is 0.2, +> on [`main`](https://github.com/lablup/ui-common/tree/main), which rebuilds +> the package on Astryx; its README explains the upgrade. To fix a 0.1 bug, +> open the pull request against `release/0.1` +> ([CONTRIBUTING.md](CONTRIBUTING.md#releasing)). + Product-neutral UI components and design tokens shared across Lablup products. Consumers are Lablup product frontends, including @@ -11,9 +18,14 @@ a component impossible to share. ## Install ``` -pnpm add @lablup/ui-common +pnpm add @lablup/ui-common@release-0.1 ``` +`release-0.1` is the newest 0.1 patch. Without it, `latest` resolves to 0.1 +only until 0.2.0 is published. 0.1 has no plain release, so its patches +continue the prerelease sequence (`0.1.0-alpha.24`, …): a `^0.1.0-alpha.N` +range picks them up, and an exact pin moves by hand. + That is npmjs, which needs no authentication and is the right route for essentially everyone, including open-source consumers and forked CI. diff --git a/scripts/dist-tag.d.mts b/scripts/dist-tag.d.mts new file mode 100644 index 0000000..3afdce5 --- /dev/null +++ b/scripts/dist-tag.d.mts @@ -0,0 +1,5 @@ +/** Types for the release dist-tag rule, so tests can import it. */ + +export declare function releaseLine(version: string): string; + +export declare function distTagFor(version: string, mainVersion: string): string; diff --git a/scripts/dist-tag.mjs b/scripts/dist-tag.mjs new file mode 100644 index 0000000..a20ba01 --- /dev/null +++ b/scripts/dist-tag.mjs @@ -0,0 +1,65 @@ +/** + * The npm dist-tag a release publishes under. + * + * `main` develops the current release line. Its versions publish under `next` + * (a prerelease) or `latest` (a plain version). An older line is maintained on + * a `release/` branch, and its hotfixes publish under `release-` + * only, so a fix to an old line never moves the tags that point at the current + * one. A line is `.` before 1.0 and `` from 1.0 on: the + * part a breaking change bumps (CONTRIBUTING.md, "Versioning"). + * + * Usage: node scripts/dist-tag.mjs + */ + +import { pathToFileURL } from "node:url"; + +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/; + +function parse(version) { + const match = SEMVER.exec(version); + if (!match) throw new Error(`Not a semver version: ${JSON.stringify(version)}`); + return { + major: Number(match[1]), + minor: Number(match[2]), + prerelease: match[4] !== undefined, + }; +} + +/** The release line a version belongs to: `0.1`, `0.2`, … then `1`, `2`, … */ +export function releaseLine(version) { + const { major, minor } = parse(version); + return major === 0 ? `0.${minor}` : `${major}`; +} + +function lineKey(version) { + const { major, minor } = parse(version); + return major === 0 ? [0, minor] : [major, 0]; +} + +/** + * @param {string} version the version being published + * @param {string} mainVersion the version in `package.json` on `main` + */ +export function distTagFor(version, mainVersion) { + const [major, minor] = lineKey(version); + const [mainMajor, mainMinor] = lineKey(mainVersion); + if (major === mainMajor && minor === mainMinor) { + return parse(version).prerelease ? "next" : "latest"; + } + if (major > mainMajor || (major === mainMajor && minor > mainMinor)) { + throw new Error( + `${version} is ahead of main (${mainVersion}). Release a new line from main, after bumping it there.`, + ); + } + return `release-${releaseLine(version)}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [version, mainVersion] = process.argv.slice(2); + if (!version || !mainVersion) { + console.error("Usage: node scripts/dist-tag.mjs "); + process.exit(2); + } + console.log(distTagFor(version, mainVersion)); +} diff --git a/src/distTag.test.ts b/src/distTag.test.ts new file mode 100644 index 0000000..2445693 --- /dev/null +++ b/src/distTag.test.ts @@ -0,0 +1,33 @@ +import { distTagFor, releaseLine } from "../scripts/dist-tag.mjs"; + +describe("releaseLine", () => { + it("is major.minor before 1.0 and major from 1.0 on", () => { + expect(releaseLine("0.1.0-alpha.23")).toBe("0.1"); + expect(releaseLine("0.2.3")).toBe("0.2"); + expect(releaseLine("1.4.0")).toBe("1"); + }); + + it("refuses something that is not a version", () => { + expect(() => releaseLine("v0.1.0")).toThrow(/Not a semver/); + }); +}); + +describe("distTagFor", () => { + it("publishes main's line under next or latest", () => { + expect(distTagFor("0.2.0-alpha.16", "0.2.0-alpha.15")).toBe("next"); + expect(distTagFor("0.2.0", "0.2.0-alpha.15")).toBe("latest"); + expect(distTagFor("1.3.0", "1.2.0")).toBe("latest"); + }); + + it("publishes an older line under its own tag, prerelease or not", () => { + expect(distTagFor("0.1.0-alpha.24", "0.2.0-alpha.15")).toBe("release-0.1"); + expect(distTagFor("0.1.1", "0.2.0")).toBe("release-0.1"); + expect(distTagFor("1.4.2", "2.0.0-rc.1")).toBe("release-1"); + expect(distTagFor("0.9.1", "1.0.0")).toBe("release-0.9"); + }); + + it("refuses a version ahead of main", () => { + expect(() => distTagFor("0.3.0-alpha.0", "0.2.0")).toThrow(/ahead of main/); + expect(() => distTagFor("1.0.0", "0.9.0")).toThrow(/ahead of main/); + }); +});