diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ead0a27..e90636e 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 6b59761..fb4a143 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,12 +22,45 @@ 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. + # main's line publishes a prerelease under `next` and a plain version + # under `latest`; an older line, released from its release/ + # branch, under `release-` only (scripts/dist-tag.mjs). 0.1's + # alphas before the release/0.1 branch went out under `alpha`; that tag + # is no longer moved. The registry still sets `latest` on a package's + # first publish, so @lablup/ui-common-cli's `latest` is its first alpha + # until 0.2.0; the docs say `@next` until then. Do not move `latest` here. + 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 (main is at $MAIN_VERSION)" + 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 @@ -80,23 +113,6 @@ jobs: fi done - # Any prerelease goes to `next`, a plain version to `latest`. 0.1's - # prereleases went out under `alpha`; that tag is no longer moved. - # The registry still sets `latest` on a package's first publish, so - # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the - # docs say `@next` until then. Do not move `latest` here. - - name: Choose the dist-tag from the version - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - case "$VERSION" in - *-*) TAG=next ;; - *) TAG=latest ;; - esac - echo "Publishing $VERSION under dist-tag $TAG" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact # version; the library goes first so the CLI's peer exists when it lands. - name: Pack both packages @@ -110,7 +126,7 @@ jobs: if: github.event_name == 'release' || inputs.dry_run == false run: | for PKG in lablup-ui-common lablup-ui-common-cli; do - npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + npm publish "$RUNNER_TEMP/packed/$PKG-${{ needs.dist-tag.outputs.version }}.tgz" --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://npm.pkg.github.com done env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -119,7 +135,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.dry_run run: | for PKG in lablup-ui-common lablup-ui-common-cli; do - npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + npm publish "$RUNNER_TEMP/packed/$PKG-${{ needs.dist-tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://npm.pkg.github.com done # GitHub Packages requires authentication even for public packages, so an @@ -131,6 +147,7 @@ jobs: github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'npmjs' runs-on: ubuntu-latest + needs: dist-tag environment: release permissions: contents: read @@ -186,23 +203,6 @@ jobs: fi done - # Any prerelease goes to `next`, a plain version to `latest`. 0.1's - # prereleases went out under `alpha`; that tag is no longer moved. - # The registry still sets `latest` on a package's first publish, so - # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the - # docs say `@next` until then. Do not move `latest` here. - - name: Choose the dist-tag from the version - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - case "$VERSION" in - *-*) TAG=next ;; - *) TAG=latest ;; - esac - echo "Publishing $VERSION under dist-tag $TAG" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact # version; the library goes first so the CLI's peer exists when it lands. - name: Pack both packages @@ -224,7 +224,7 @@ jobs: if: github.event_name == 'release' || inputs.dry_run == false run: | for PKG in lablup-ui-common lablup-ui-common-cli; do - npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + npm publish "$RUNNER_TEMP/packed/$PKG-${{ needs.dist-tag.outputs.version }}.tgz" --provenance --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://registry.npmjs.org done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -233,7 +233,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.dry_run run: | for PKG in lablup-ui-common lablup-ui-common-cli; do - npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + npm publish "$RUNNER_TEMP/packed/$PKG-${{ needs.dist-tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ needs.dist-tag.outputs.tag }} --registry https://registry.npmjs.org done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index fcff8f3..7bde1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Changed + +- 0.1 is maintained on the `release/0.1` branch. Its patch releases publish + under the `release-0.1` dist-tag and never move `next` or `latest`; the + publish workflow picks the tag by comparing the version with `main`'s + (`scripts/dist-tag.mjs`). README's Install now names `@next`, since + `latest` is still 0.1 until 0.2.0. + ## [0.2.0-alpha.15] Hardening for the apps moving off 0.1: ui-common's styles now sit in their diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecaa7bd..c61f24e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -468,6 +468,8 @@ contract), and the CSS entry point paths. While the API is migrating, releases are prereleases (`0.2.0-alpha.N`). Before 1.0, a breaking change bumps the minor version. +An older line keeps getting patch releases from its `release/` branch +([Patching an older line](#patching-an-older-line)). ## Pull requests @@ -479,12 +481,75 @@ today and who does the move. ## Releasing -1. Update the version in `package.json` and add a `CHANGELOG.md` entry. +`main` releases the current line. An older line is patched on its own +`release/` branch ([Patching an older line](#patching-an-older-line)). +`scripts/dist-tag.mjs` picks the npm dist-tag by comparing the version with +the one on `main`: + +| Version | Dist-tag | +| -------------------------------- | ---------------- | +| main's line, prerelease | `next` | +| main's line, plain | `latest` | +| an older line, prerelease or not | `release-` | +| a line ahead of main | refused | + +A line is `.` before 1.0 and `` after. The `alpha` tag, +which 0.1 published under before `release/0.1` existed, is no longer moved. + +### From main + +1. Update the version in `package.json` and `packages/cli/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. + that the tag matches both `package.json` files 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. + the built-in `GITHUB_TOKEN` for GitHub Packages, and `NPM_TOKEN` for + npmjs. + +### Patching an older line + +0.1 is maintained on `release/0.1`, cut from `v0.1.0-alpha.23`, its last +release before `main` moved to 0.2. When `main` moves to a new line, cut the +outgoing line's branch from its last release tag: + +``` +git push origin v0.2.:refs/heads/release/0.2 +``` + +A patch carries fixes only: the "Patch" row of [Versioning](#versioning). No +new components, props or exports, and no Astryx bump. + +1. **Fix.** 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. 0.2 rebuilt every component on Astryx, so most 0.1 bugs exist + only in 0.1: say in the pull request whether `main` is affected. +2. **Version.** In the same or a follow-up pull request against + `release/0.1`, bump `package.json` and add the `CHANGELOG.md` entry there. + 0.1 has no plain release, so its patches continue the prerelease sequence + (`0.1.0-alpha.24`, …); a line that has a plain release patches as + `.` (`0.2.1`). Publishing refuses only a version ahead of + `main`'s line, not one from another older line, so check the version + before tagging. +3. **Release.** Create the GitHub release on the branch, never on `main`: + + ``` + 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. + + A release runs the publish workflow as it is at the tagged commit, so the + branch's own `publish.yml` publishes, and it moves only `release-0.1`. + `--latest=false` keeps GitHub's "Latest" badge on the current line. + +4. **Check** `npm view @lablup/ui-common dist-tags`: `release-0.1` is the new + version and `next` and `latest` did not move. +5. **Record it on main.** Copy the entry into `main`'s `CHANGELOG.md`, in + version order below the 0.2 entries, so `main`'s changelog lists every + release. ## Pre-public review diff --git a/README.md b/README.md index 576f7da..19198c3 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,41 @@ Consumers are Lablup product frontends, including only. It has no API client, no application state, no router, and no desktop-shell integration. +## Versions + +| Line | Branch | State | Install | +| ---- | ------------- | ---------------------------- | ------------------------------- | +| 0.2 | `main` | Developed here (prereleases) | `@lablup/ui-common@next` | +| 0.1 | `release/0.1` | Maintained: fixes only | `@lablup/ui-common@release-0.1` | + +Until 0.2.0 is published, the `latest` dist-tag still points at 0.1's last +alpha, so a bare `pnpm add @lablup/ui-common` installs 0.1: name `@next` for +0.2. + +An app that stays on 0.1 keeps getting fixes as patch releases of the 0.1 +line, published under the `release-0.1` dist-tag, never under `next` or +`latest`. 0.1 never had a plain release, so its fixes continue its prerelease +sequence (`0.1.0-alpha.24`, `0.1.0-alpha.25`, …), which a `^0.1.0-alpha.N` +range already accepts; an exact pin moves by hand: + +``` +pnpm add @lablup/ui-common@release-0.1 +``` + +0.1's own README and CHANGELOG are on the +[`release/0.1`](https://github.com/lablup/ui-common/tree/release/0.1) branch. +To fix a bug in 0.1, open the pull request against `release/0.1` +([CONTRIBUTING.md](CONTRIBUTING.md#patching-an-older-line)). Moving to 0.2 is +[Upgrading from 0.1](#upgrading-from-01). + ## Install ``` -pnpm add @lablup/ui-common @stylexjs/stylex +pnpm add @lablup/ui-common@next @stylexjs/stylex ``` -That is npmjs, which needs no authentication. +That is npmjs, which needs no authentication. Drop `@next` once 0.2.0 is +published ([Versions](#versions)). Peer dependencies: 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/); + }); +});