Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"changelog": false,
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
},
"changedFilePatterns": [
"**",
"!**/*.test.{js,jsx,ts,tsx,mjs}",
"!**/test/**",
"!**/__mocks__/**",
"!**/bench/**",
"!**/demo/**",
"!**/stories/**",
"!**/.storybook/**",
"!**/dist/**",
"!**/build/**",
"!**/coverage/**",
"!**/coverage.json",
"!**/.tscache/**",
"!**/scripts/**",
"!**/grunt/**",
"!**/Gruntfile.js",
"!**/Makefile",
"!**/rollup.config.*",
"!**/rollup.resources.mjs",
"!**/vite.config.*",
"!**/vitest.workspace.*",
"!**/jest.config.js",
"!**/jest.react18.config.mjs",
"!**/karma.conf.js",
"!**/tsconfig*.json",
"!**/dts-generator.config.js",
"!**/api-extractor.json",
"!**/knip.json",
"!**/typedoc.*",
"!**/.prettierrc*",
"!**/.gitignore",
"!**/eslint.config.mjs",
"!**/*.md",
"!**/LICENSE"
],
"ignore": []
}
2 changes: 2 additions & 0 deletions .changeset/light-horses-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 2 additions & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ Thank you for submitting a pull request!

Please verify that:
* [ ] Code is up-to-date with the `master` branch
* [ ] You've successfully run `grunt test` locally
* [ ] You've successfully run `yarn test` locally
* [ ] If applicable, there are new or updated unit tests validating the change
* [ ] If applicable, there are new or updated @types
* [ ] If applicable, documentation has been updated
* [ ] If the change is releasable, you've added a changeset (`yarn changeset`) — see CONTRIBUTING.md
-->

## Description
Expand Down
133 changes: 133 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Phase 2 of the release pipeline: publish, tag, release, and refresh `prod`.
#
# Fires when the release PR opened by release.yml (source branch `release/pending`) is
# merged into `master`. It keys off the merged pull_request event + that branch name,
# NOT the commit message, so legacy/manual "Release v…" commits (or releases on other
# branches like `dev`) never trigger it. It:
# 1. publishes every changed public package to npm with Yarn (NOT changeset publish),
# 2. tags v<core-version> + cuts one aggregated GitHub Release — only when core changed
# (detected by the tag not existing yet),
# 3. force-updates `prod` to the merged release commit — always.
#
# Channel: chosen in Phase 1 (release.yml `dist_tag` input) and carried here via the
# committed RELEASE_CHANNEL file. `latest` publishes stable (npm `--tag latest`, GitHub
# Release `--latest`); `alpha`/`beta` publish under that dist-tag as a `--prerelease`.
#
# Required repository secrets:
# NPM_TOKEN — npm automation token (org 2FA must allow automation tokens)
# RELEASE_APP_ID — GitHub App id (contents:write to push tags + prod)
# RELEASE_APP_PRIVATE_KEY — GitHub App private key
name: Release (publish)

on:
pull_request:
types:
- closed
branches:
- master

concurrency:
group: release-publish
cancel-in-progress: false

jobs:
publish:
# Only when our release PR (source branch release/pending, opened by release.yml)
# is actually merged into master (not just closed)
if: ${{ github.repository == 'clientIO/joint' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'release/pending' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

- name: Checkout master
uses: actions/checkout@v4
with:
ref: master
# Need tags to detect whether v<core-version> already exists.
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22.14.0
registry-url: 'https://registry.npmjs.org'

- name: Cache Yarn
uses: actions/cache@v4
with:
path: |
.yarn/cache
key: yarn-cache-${{ hashFiles('yarn.lock') }}

- name: Install dependencies
run: yarn install --immutable

# Derive the release facts from repository state (changesets are already
# consumed at this point, so we cannot re-run the release plan).
- name: Derive release facts
id: facts
run: |
CORE_VERSION=$(node -p "require('./packages/joint-core/package.json').version")
echo "core_version=$CORE_VERSION" >> "$GITHUB_OUTPUT"

# Channel is chosen in Phase 1 (dist_tag input) and carried in RELEASE_CHANNEL.
# `latest` = stable; anything else = prerelease. Default to latest if absent.
DIST_TAG="$(cat RELEASE_CHANNEL 2>/dev/null || echo latest)"
DIST_TAG="${DIST_TAG//[[:space:]]/}"
[ -n "$DIST_TAG" ] || DIST_TAG=latest
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
if [ "$DIST_TAG" = "latest" ]; then
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
fi

# Core changed this release iff its tag does not exist yet.
if git rev-parse -q --verify "refs/tags/v$CORE_VERSION" >/dev/null; then
echo "core_changed=false" >> "$GITHUB_OUTPUT"
else
echo "core_changed=true" >> "$GITHUB_OUTPUT"
fi

# Publish with Yarn (resolves workspace: ranges; skips prepublishOnly guards;
# topological order; --no-private excludes examples; --tolerate-republish skips
# already-published versions).
- name: Publish to NPM via Yarn
env:
YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
yarn release-publish --tag "${{ steps.facts.outputs.dist_tag }}"

# Create tag + GitHub Release - only when @joint/core changed
- name: Tag & GitHub Release
if: ${{ steps.facts.outputs.core_changed == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
CORE_VERSION="${{ steps.facts.outputs.core_version }}"
git tag "v$CORE_VERSION"
git push origin "v$CORE_VERSION"
NOTES_ARG=(--notes-file RELEASE_NOTES.md)
[ -s RELEASE_NOTES.md ] || NOTES_ARG=(--generate-notes)
if [ "${{ steps.facts.outputs.is_prerelease }}" = "true" ]; then
gh release create "v$CORE_VERSION" --title "Release v$CORE_VERSION" "${NOTES_ARG[@]}" --prerelease
else
gh release create "v$CORE_VERSION" --title "Release v$CORE_VERSION" "${NOTES_ARG[@]}" --latest
fi

# Refresh `prod` branch to the merged release commit
- name: Refresh prod branch
run: git push --force origin master:prod

# Delete `release/pending` branch
- name: Delete release branch
run: git push origin --delete release/pending || echo "release/pending already deleted"
153 changes: 153 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Phase 1 of the release pipeline: prepare the release and open the release PR.
#
# The maintainer's "release button": dispatch this workflow. It renders the root
# CHANGELOG + RELEASE_NOTES.md from the pending changesets, applies the version
# bumps (`changeset version`), commits the result to a `release/pending` branch, and
# opens an auto-merging PR to `master`. Merging that PR triggers Phase 2
# (`publish.yml`), which publishes to npm, tags, cuts the GitHub Release, and
# refreshes `prod` — all automatically.
#
# Release channel: pick the `dist_tag` input (latest | alpha | beta). It is the single
# source of truth — carried to Phase 2 via the committed RELEASE_CHANNEL file, which
# publishes under that npm dist-tag: `latest` as a stable GitHub Release, `alpha`/`beta`
# as a prerelease. For prerelease *version numbers* (x.y.z-beta.N), additionally run
# `yarn changeset pre enter <tag>` on master before dispatching (and `pre exit` to
# leave pre mode) — that only affects the numbering, not the channel.
#
# Required repository secrets:
# RELEASE_APP_ID — GitHub App id (App must have contents:write + pull-requests:write)
# RELEASE_APP_PRIVATE_KEY — GitHub App private key
# (Using an App token, not GITHUB_TOKEN, so the opened PR triggers test-pr.yml.)
name: Release (prepare)

on:
workflow_dispatch:
inputs:
dist_tag:
description: 'npm dist-tag / release channel. latest = stable Release; alpha/beta = prerelease.'
type: choice
options:
- latest
- alpha
- beta
default: latest
dry_run:
description: 'Render + version + build, but do not push the branch or open the PR.'
type: boolean
default: false

# Prevent overlapping release preparations.
concurrency:
group: release-prepare
cancel-in-progress: false

jobs:
prepare:
if: ${{ github.repository == 'clientIO/joint' }}
runs-on: ubuntu-latest
steps:
# A GitHub App token (not GITHUB_TOKEN) so the release PR triggers CI.
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

# Full history: the changelog renderer resolves each row's `master` commit
# (for the GitHub Release notes) from when its changeset was added.
- name: Checkout master
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22.14.0

- name: Cache Yarn
uses: actions/cache@v4
with:
path: |
.yarn/cache
key: yarn-cache-${{ hashFiles('yarn.lock') }}

- name: Install dependencies
run: yarn install --immutable

# Nothing to release if there are no changesets (README.md excluded).
- name: Guard — changesets present
run: |
shopt -s nullglob
count=0
for f in .changeset/*.md; do
[ "$(basename "$f")" = "README.md" ] || count=$((count+1))
done
if [ "$count" -eq 0 ]; then
echo "::error::No changesets found — nothing to release."
exit 1
fi
echo "Found $count changeset(s)."

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Writes info values to $GITHUB_OUTPUT:
# steps.info.outputs.release_title / core_version / core_changed / released.
- name: Version packages
id: info
run: yarn release-version

# Make sure the bumped repo still builds.
- name: Sanity build
run: yarn dist

- name: Push branch & open release PR
if: ${{ inputs.dry_run == false }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_TITLE: ${{ steps.info.outputs.release_title }}
run: |
BRANCH="release/pending"
# Carry the chosen release channel to Phase 2 (publish.yml).
echo "${{ inputs.dist_tag }}" > RELEASE_CHANNEL
git checkout -B "$BRANCH"
git add -A
git commit -m "$RELEASE_TITLE"
git push --force origin "$BRANCH"
# Reuse an OPEN PR for this branch if one exists (refreshing its title/body,
# since the force-push just replaced the contents); otherwise open a new one.
# Restricting to `--state open` avoids matching a previous, already-merged
# release PR that still carries this head-ref name in history.
PR_NUMBER="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')"
if [ -n "$PR_NUMBER" ]; then
echo "Reusing open PR #$PR_NUMBER for $BRANCH; refreshing title/body."
gh pr edit "$PR_NUMBER" --title "$RELEASE_TITLE" --body-file RELEASE_NOTES.md
else
PR_NUMBER="$(basename "$(gh pr create --base master --head "$BRANCH" \
--title "$RELEASE_TITLE" --body-file RELEASE_NOTES.md)")"
echo "Opened PR #$PR_NUMBER for $BRANCH."
fi
# Cosmetic label for humans only (NOT the publish trigger — publish.yml keys
# off the release/pending branch). Best-effort: skip if the label is absent.
gh pr edit "$PR_NUMBER" --add-label release 2>/dev/null \
|| echo "note: 'release' label not applied (optional; create it in repo labels if wanted)"
gh pr merge "$PR_NUMBER" --auto --squash

- name: Dry-run summary
if: ${{ inputs.dry_run == true }}
run: |
echo "## Dry run — release NOT pushed" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "channel: ${{ inputs.dist_tag }}" >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.info.outputs.release_title }}" >> "$GITHUB_STEP_SUMMARY"
echo "released: ${{ steps.info.outputs.released }}" >> "$GITHUB_STEP_SUMMARY"
echo '' >> "$GITHUB_STEP_SUMMARY"
echo "--- CHANGELOG (new top) ---" >> "$GITHUB_STEP_SUMMARY"
head -n 40 CHANGELOG >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
10 changes: 10 additions & 0 deletions .github/workflows/test-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ jobs:
id: install-dependencies
run: yarn install --immutable

# A PR touching releasable files must add a changeset
# (See `.changeset/config.json`)
# The release PR (branch `release/*`) is exempt - it consumes changesets
- name: Enforce changeset
id: changeset-status
if: ${{ github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release/') }}
run: |
git fetch origin master:refs/remotes/origin/master || true
yarn release-status --since=origin/master

# Build the joint project
- name: Prepare joint
id: prepare-joint
Expand Down
Loading
Loading