diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..5b7bfa8 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +# Categories for the notes GitHub generates when the deploy workflow creates a release. +# The labels are set from the conventional-commit prefix of the pull request title by +# .github/workflows/label-pr.yml. +changelog: + exclude: + labels: + - internal + - release + categories: + - title: Breaking changes + labels: + - breaking + - title: New features + labels: + - feature + - title: Fixes + labels: + - fix + # Titles without a conventional prefix, typically outside contributions. Retitle the pull + # request to move it. + - title: Other changes + labels: + - '*' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 805d7bf..7cbbc37 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,14 @@ name: Deploy # About this workflow: -# It is triggered on push events to branches production and testing. Then it performs a checkout of the current repo +# It is triggered on push events to the production branch. Then it performs a checkout of the current repo # and sets up a node environment (see .nvmrc). Following, it will run `npm ci` to build the package. Next, it will look at your # commit message, if it includes '#patch', '#minor', or '#major' it will bump the package version accordingly. -# Finally, the `npm publish` command will be run, when on branch testing it will run `npm publish --tag beta` to publish -# it under the beta flag on npm. Note: if no '#patch', '#minor', or '#major' flag is present in the latest commit -# AND the package version is not bumped manually the publish step will fail because we can not publish to an existing -# version. +# Finally, the `npm publish` command will be run. Note: if no '#patch', '#minor', or '#major' flag is present in the latest +# commit AND the package version is not bumped manually the publish step will fail because we can not publish to an +# existing version. +# Once npm has accepted the version, a GitHub release with generated notes is created. +# See .github/release.yml for how the notes are grouped. # GitHub repo configuration: # 1. Go to Manage access and add 'Github Actions' team with role: admin. @@ -16,13 +17,12 @@ name: Deploy # Note: make sure to commit package-lock.json, this is needed for `npm ci`. -# Defines the trigger for this action, in general you want it to run when pushing to production | testing. For more +# Defines the trigger for this action, in general you want it to run when pushing to production. For more # information see: https://help.github.com/en/actions/automating-your-workflow-with-github-actions/events-that-trigger-workflows#about-workflow-events) on: push: branches: - production - - testing workflow_dispatch: inputs: versionBumpType: @@ -33,9 +33,9 @@ jobs: deploy_to_npm: name: Version Bump & Deploy to NPM - # Only run this job if initiator is not the Homey Github Actions Bot to prevent loops and check if if a version bump is provided and current branch is production or testing + # Only run this job if initiator is not the Homey Github Actions Bot to prevent loops and check if a version bump is provided and current branch is production if: github.actor != 'homey-bot' - && (github.ref == 'refs/heads/production' || github.ref == 'refs/heads/testing') + && github.ref == 'refs/heads/production' && (github.event.inputs.versionBumpType == 'patch' || github.event.inputs.versionBumpType == 'minor' || github.event.inputs.versionBumpType == 'major' || contains(github.event.head_commit.message, '#patch') || contains(github.event.head_commit.message, '#minor') || contains(github.event.head_commit.message, '#major')) runs-on: ubuntu-latest @@ -53,6 +53,8 @@ jobs: with: # The token below is only necessary if you want to push the version bump to a protected branch token: ${{ secrets.HOMEY_GITHUB_ACTIONS_BOT_PERSONAL_ACCESS_TOKEN }} + # Full history and tags, so the release step can find the previous tag. + fetch-depth: 0 # Set git config to reflect Homey Github Actions Bot user - name: Set up HomeyGithubActionsBot git user @@ -79,8 +81,8 @@ jobs: run: npm ci # Nothing below this point runs if the package does not lint, format or test - # cleanly, so a push to testing cannot publish a beta that was never checked. - # Ahead of the docs build, which is the slowest step here. + # cleanly, so nothing reaches npm that was never checked. Ahead of the docs build, + # which is the slowest step here. - name: Validate run: | npm run lint @@ -111,20 +113,33 @@ jobs: git pull git push --follow-tags - # Publish when this action is running on branch production. # No NPM_AUTH_TOKEN: auth is brokered via OIDC against the npm # Trusted Publisher configured on the package on npmjs.com. - name: Publish - if: github.ref == 'refs/heads/production' + run: npm publish --provenance + + # After the publish, and starting from the previous release rather than the previous tag, + # because a publish that fails after the version bump leaves a tag without an npm version + # behind (v2.7.0, v3.1.0). Until the first release exists, the start is the last version npm + # actually has, for the same reason. If this step fails, create the release by hand: a rerun + # would bump again. + - name: Create GitHub release + # Explicit, because only then does Actions add pipefail: without it a failing gh api still + # feeds the Node filter an empty input, and an empty release gets published. + shell: bash + env: + GH_TOKEN: ${{ secrets.HOMEY_GITHUB_ACTIONS_BOT_PERSONAL_ACCESS_TOKEN }} run: | - npm publish --provenance VERSION="$(node -p "require('./package.json').version")" - echo package_version=${VERSION} >> $GITHUB_ENV - - # Publish to beta when this action is running on branch testing. - - name: Publish to beta - if: github.ref == 'refs/heads/testing' - run: | - npm publish --provenance --tag beta - VERSION="$(node -p "require('./package.json').version")@beta" - echo package_version=${VERSION} >> $GITHUB_ENV + TAG="v$VERSION" + if ! PREVIOUS_TAG="$(gh release view --json tagName -q .tagName)"; then + PREVIOUS_TAG="v$(npm view "zigbee-clusters@<$VERSION" version --json \ + | node -p "[].concat(JSON.parse(require('node:fs').readFileSync(0, 'utf8'))).at(-1)")" + fi + # The notes list each pull request title as is. Drop its conventional-commit prefix, which the + # category heading above it already says. + gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$TAG" -f previous_tag_name="$PREVIOUS_TAG" --jq .body \ + | node -e "process.stdout.write(require('node:fs').readFileSync(0, 'utf8').replace(/^\* \w+(\([^)]*\))?!?: (.)/gm, (_, _scope, first) => '* ' + first.toUpperCase()))" \ + > release-notes.md + gh release create "$TAG" --verify-tag --title "$TAG" --notes-file release-notes.md diff --git a/.github/workflows/generate-types.yml b/.github/workflows/generate-types.yml index 3dc4791..aa17c5a 100644 --- a/.github/workflows/generate-types.yml +++ b/.github/workflows/generate-types.yml @@ -4,7 +4,6 @@ on: push: branches-ignore: - production - - testing paths: - 'lib/clusters/**/*.js' - 'scripts/generate-types.mts' diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml new file mode 100644 index 0000000..3123d34 --- /dev/null +++ b/.github/workflows/label-pr.yml @@ -0,0 +1,67 @@ +name: Label pull request + +# Labels each pull request from the conventional-commit prefix of its title, so the release +# notes can sort it (see .github/release.yml). Re-runs when the title or base branch is edited. +# The five labels below follow the title alone: to move a pull request, retitle it. +# +# pull_request_target so pull requests from forks get a token that can label. This is safe +# because the job never checks out or runs code from the pull request. +on: + pull_request_target: + types: [opened, edited, reopened] + +permissions: + pull-requests: write + +# One run at a time per pull request, so two quick edits cannot each add a label the other +# should have removed. +concurrency: + group: label-pr-${{ github.event.pull_request.number }} + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const MANAGED_LABELS = ['breaking', 'feature', 'fix', 'internal', 'release']; + const INTERNAL_TYPES = ['build', 'chore', 'ci', 'docs', 'refactor', 'style', 'test']; + + function labelFor(title, baseRef) { + if (baseRef === 'production') return 'release'; + const match = /^(\w+)(?:\([^)]*\))?(!)?:/.exec(title); + if (!match) return null; + const [, type, breaking] = match; + if (breaking) return 'breaking'; + if (type === 'feat') return 'feature'; + if (type === 'fix' || type === 'perf') return 'fix'; + if (INTERNAL_TYPES.includes(type)) return 'internal'; + return null; + } + + const { action, changes = {} } = context.payload; + // The label depends on the title and the base branch; other edits change nothing. + if (action === 'edited' && !changes.title && !changes.base) return; + + // The payload is a snapshot from when the event fired. Read the pull request as it is + // now, so labels an earlier run added are seen and the latest title wins. + const { data: pullRequest } = await github.rest.pulls.get({ + ...context.repo, + pull_number: context.payload.pull_request.number, + }); + + // The managed labels are a function of the current title and base only, so whichever + // runs happen to execute, the last one leaves them right. No prefix means none of them. + const wanted = labelFor(pullRequest.title, pullRequest.base.ref); + const current = pullRequest.labels.map(label => label.name); + const issue = { ...context.repo, issue_number: pullRequest.number }; + + for (const name of current) { + if (MANAGED_LABELS.includes(name) && name !== wanted) { + await github.rest.issues.removeLabel({ ...issue, name }); + } + } + if (wanted && !current.includes(wanted)) { + await github.rest.issues.addLabels({ ...issue, labels: [wanted] }); + } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 39e9edd..a738130 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,6 @@ on: branches: - master - production - - testing pull_request: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 540f04d..1eb649a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,6 @@ on: branches: - master - production - - testing pull_request: jobs: diff --git a/AGENTS.md b/AGENTS.md index 8e48c02..56ac6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ Run single test file: node --test test/onOff.js ``` +## Pull Requests and Releases + +Pull request titles use Conventional Commits, `type(scope): Description` with a capital letter after the prefix. The prefix decides where the change lands in the release notes: `feat` under New features, `fix` and `perf` under Fixes, a `!` before the colon under Breaking changes, and `build`, `chore`, `ci`, `docs`, `refactor`, `style` and `test` are left out. Use `feat` or `fix` for anything someone using the library would notice. How a release is cut is described under [Releasing](README.md#releasing). + ## Architecture Zigbee Cluster Library (ZCL) implementation for Node.js, designed for Homey's Zigbee stack. diff --git a/README.md b/README.md index ff4ef98..38fd07a 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ v2.0.0 zclNode.endpoints[1].clusters.basic.readAttributes(['modelId', 'manufacturerName']); ``` -## Release +## Changelog -Merge to production and include `#patch`, `#minor` or `#major` in the PR/commit message. Or merge to production and run the "Deploy" workflow and provide a version bump parameter. +What changed in each version is listed in the [GitHub releases](https://github.com/athombv/node-zigbee-clusters/releases). How those notes are put together is described under [Releasing](#releasing). ## About Zigbee Clusters @@ -323,21 +323,54 @@ To regenerate TypeScript types after modifying cluster definitions: npm run generate-types ``` -This runs `scripts/generate-types.js` which loads all cluster modules and generates typed interfaces. +This runs `scripts/generate-types.mts` which loads all cluster modules and generates typed interfaces. ### Automatic Generation (GitHub Actions) -TypeScript types are automatically regenerated when changes are pushed to the `develop` branch that affect: +TypeScript types are automatically regenerated when a push to any branch other than `production` changes: - `lib/clusters/**/*.js` - cluster definitions -- `scripts/generate-types.js` - the generator script +- `lib/Cluster.js`, `lib/constants.js` and `index.js` +- `scripts/generate-types.mts` and `scripts/template.d.ts.txt` - the generator and its template -The workflow commits updated types back to `develop` if changes are detected. +The workflow commits updated types back to that branch if changes are detected. ## Contributing Great if you'd like to contribute to this project, a few things to take note of before submitting a PR: -- This project enforces ESLint, validate by running `npm run lint`. -- This project implements a basic test framework based on node:test, see [test](https://github.com/athombv/node-zigbee-clusters/blob/production/test) directory. -- This project uses several [GitHub Action workflows](https://github.com/athombv/node-zigbee-clusters/blob/production/.github/workflows) (e.g. ESLint, running test and versioning/publishing). +- This project enforces ESLint and Prettier: check with `npm run lint` and `npm run format:check`, and fix formatting with `npm run format:write`. +- Tests use the built-in node:test runner, see the [test](https://github.com/athombv/node-zigbee-clusters/blob/production/test) directory. +- Pull request titles follow [Conventional Commits](https://www.conventionalcommits.org/), for example `fix(onOff): Handle a missing attribute`. The title becomes a line in the release notes, so write it for someone using the library. See [Release notes](#release-notes). +- [GitHub Action workflows](https://github.com/athombv/node-zigbee-clusters/blob/production/.github/workflows) run ESLint, Prettier and the tests, and publish releases. + +## Releasing + +Releases are published from the `production` branch by the Deploy workflow (`.github/workflows/deploy.yml`). + +1. Open a pull request from `master` into `production` with `#patch`, `#minor` or `#major` in its title, for example `Merge master into production (#minor)`. +2. Merge it with a merge commit or a squash merge, not a rebase merge. The workflow reads the marker from the commit message that lands on `production`, and only those two carry the pull request title into it. + +The workflow then lints, checks formatting and runs the tests, builds, bumps the version and pushes the version commit and tag, publishes to npm with [trusted publishing](https://docs.npmjs.com/trusted-publishers), and creates a GitHub release. + +Instead of using a marker, you can also start the Deploy workflow by hand on `production` and pass the version bump as input. + +If creating the GitHub release fails after the npm publish, create it by hand. Running the workflow again would bump the version a second time. + +### Release notes + +GitHub generates the notes from the titles of the pull requests merged since the previous release, grouped by label. A workflow sets that label from the title's prefix, so a pull request lands in the right section on its own: + +| Title prefix | Section | +| ----------------------------------------------------------- | ---------------- | +| `feat` | New features | +| `fix`, `perf` | Fixes | +| `!` before the colon, as in `feat!:` | Breaking changes | +| `build`, `chore`, `ci`, `docs`, `refactor`, `style`, `test` | Left out | +| No prefix | Other changes | + +The prefix itself is stripped from each line, and release pull requests into `production` are left out. + +To move a pull request to another section, change its title, also after it was merged: the label follows. To reword a line, edit the release on GitHub after it is published. + +The grouping is configured in `.github/release.yml`, the labeling in `.github/workflows/label-pr.yml`.