Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ How did you solve the problem?
Show the fixed output if appropriate.
-->

## ChangeLog
## Release

<!--
Optional. Suggest a line for adding to the CHANGELOG to summarise your change.
Merging a PR into main that bumps the version publishes it (docs/releasing.md). Pick one:
-->

- [ ] Releases vX.Y.Z: ran `bin/bump …` and described it in CHANGELOG.md
- [ ] No release: `bin/be` is unchanged
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

# Pull requests run it directly; the Release workflow calls it on every push to main, so the
# merge result is tested again before anything is published.

on:
pull_request:
workflow_call:

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

jobs:
test:
name: Test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# macOS is what the maintainers run, with its bash 3.2.
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm run test:host

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm run lint

version:
name: Version
# Only a PR into main cuts a release, so only there do the release rules apply.
if: github.event_name == 'pull_request' && github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: scripts/release/verify-version.sh pr "origin/${BASE_REF}" "${HEAD_REF}"
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
102 changes: 102 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Release

# A merge into main that bumped the version publishes it to public npm and tags it with a GitHub
# Release; any other merge finds nothing to do. Every step checks what is already done, so re-running
# a failed run is safe. See docs/releasing.md.
#
# npm trusts this file by name (Trusted Publisher: beplus/be, release.yml, environment npm), so
# there is no npm token anywhere: the publish job authenticates with its OIDC token.

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

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

jobs:
ci:
name: CI
uses: ./.github/workflows/ci.yml

plan:
name: Plan
needs: ci
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.verify.outputs.version }}
tag: ${{ steps.verify.outputs.tag }}
dist_tag: ${{ steps.verify.outputs.dist_tag }}
need_publish: ${{ steps.verify.outputs.need_publish }}
need_release: ${{ steps.verify.outputs.need_release }}
steps:
- uses: actions/checkout@v7
- id: verify
run: scripts/release/verify-version.sh release

publish:
name: Publish ${{ needs.plan.outputs.tag }}
needs: plan
if: needs.plan.outputs.need_publish == 'true' || needs.plan.outputs.need_release == 'true'
runs-on: ubuntu-latest
environment: npm
permissions:
contents: write # tag + GitHub Release
id-token: write # npm trusted publishing and provenance
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org

- name: Make sure npm can publish with OIDC (>= 11.5.1)
run: |
if ! node -e '
const [a, b, c] = process.argv[1].split(".").map(Number);
process.exit(a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))) ? 0 : 1);
' "$(npm --version)"; then
npm install --global npm@11
fi
npm --version

- name: Publish to npm
if: needs.plan.outputs.need_publish == 'true'
run: npm publish --provenance --tag "${DIST_TAG}"
env:
DIST_TAG: ${{ needs.plan.outputs.dist_tag }}

- name: Tag and create the GitHub Release
if: needs.plan.outputs.need_release == 'true'
run: |
scripts/release/changelog-notes.sh "${TAG}" > "${RUNNER_TEMP}/notes.md"
if [[ "${DIST_TAG}" == "latest" ]]; then
channel=(--latest)
else
channel=(--prerelease --latest=false)
fi
gh release create "${TAG}" --target "${GITHUB_SHA}" --title "${TAG}" \
--notes-file "${RUNNER_TEMP}/notes.md" "${channel[@]}"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.plan.outputs.tag }}
DIST_TAG: ${{ needs.plan.outputs.dist_tag }}

- name: Summary
run: |
{
echo "### Released ${TAG}"
echo
echo "- npm: https://www.npmjs.com/package/@beplus/be/v/${VERSION}"
echo "- GitHub: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
} >> "${GITHUB_STEP_SUMMARY}"
env:
TAG: ${{ needs.plan.outputs.tag }}
VERSION: ${{ needs.plan.outputs.version }}
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

https://github.com/beplus/be/compare/main...dev
https://github.com/beplus/be/compare/v0.7.0...main

## [v0.7.0] (07/03/2026)

Expand Down
94 changes: 94 additions & 0 deletions bin/bump
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# Prepare a release on the current branch:
#
# bin/bump patch|minor|major|X.Y.Z
#
# Writes the new version into package.json, package-lock.json and bin/be, and opens its section in
# CHANGELOG.md, moving any notes written under [Unreleased] into it. Commits, tags and publishes
# nothing: put the result in a PR into main, and merging that PR releases it (docs/releasing.md).
#
# On a _versions/vX.Y.Z branch the argument defaults to X.Y.Z, and any other version is refused.
# Re-running it for the version already set only re-syncs the files.

set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."

branch="$(git branch --show-current)"
branch_version=""
if [[ "${branch}" =~ ^_versions/v(.+)$ ]]; then
branch_version="${BASH_REMATCH[1]}"
fi

bump="${1:-${branch_version}}"
if [[ -z "${bump}" ]]; then
echo "usage: bin/bump patch|minor|major|X.Y.Z" >&2
exit 2
fi
bump="${bump#v}"

# npm computes the version; keep a copy so that a refused version leaves nothing behind.
backup="$(mktemp -d)"
trap 'rm -rf "${backup}"' EXIT
cp package.json package-lock.json "${backup}/"

npm version "${bump}" --no-git-tag-version --allow-same-version > /dev/null
version="$(node -p 'require("./package.json").version')"

if [[ -n "${branch_version}" && "${version}" != "${branch_version}" ]]; then
cp "${backup}/package.json" "${backup}/package-lock.json" .
echo "Error: branch ${branch} cuts v${branch_version}, not v${version}." >&2
exit 1
fi

sed -i.bak "s/^VERSION=\".*\"\$/VERSION=\"v${version}\"/" bin/be
rm -f bin/be.bak

#
# CHANGELOG.md: point [Unreleased] at what main has on top of this version, and open the version's
# section with whatever [Unreleased] collected (kept as is when the section already exists).
#

has_section=0
if awk -v heading="## [v${version}]" 'index($0, heading) == 1 { found = 1 } END { exit !found }' CHANGELOG.md; then
has_section=1
fi

awk \
-v tag="v${version}" \
-v date="$(date +%m/%d/%Y)" \
-v link="$(node -p 'require("./package.json").homepage')/compare/v${version}...main" \
-v has_section="${has_section}" '
function flush( i, first, last) {
print ""
print link
print ""
first = 1
while (first <= n && notes[first] !~ /[^[:space:]]/) first++
last = n
while (last >= first && notes[last] !~ /[^[:space:]]/) last--
if (!has_section) {
print "## [" tag "] (" date ")"
print ""
}
for (i = first; i <= last; i++) print notes[i]
if (last >= first) print ""
in_unreleased = 0
}
/^## \[Unreleased\]/ { print; in_unreleased = 1; next }
in_unreleased && /^## \[/ { flush(); print; next }
in_unreleased {
if ($0 !~ /^https?:\/\/[^ ]*\/compare\//) notes[++n] = $0
next
}
{ print }
END { if (in_unreleased) flush() }
' CHANGELOG.md > "${backup}/CHANGELOG.md"
cat "${backup}/CHANGELOG.md" > CHANGELOG.md

echo "Bumped to v${version}: package.json, package-lock.json, bin/be, CHANGELOG.md."
if ! scripts/release/changelog-notes.sh "v${version}" > /dev/null 2>&1; then
echo "Next: describe the release under \"## [v${version}]\" in CHANGELOG.md."
fi
echo "Then commit and open a PR into main; merging it publishes v${version}."
49 changes: 0 additions & 49 deletions bin/release

This file was deleted.

62 changes: 62 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Releasing

A release is cut by merging a pull request into `main` that bumps the version. Nobody publishes
by hand. The [Release workflow](../.github/workflows/release.yml) publishes `@beplus/be` to the
**public npm registry** and creates the `vX.Y.Z` tag and GitHub Release.

## The flow

```
feature/… ──▶ (optional) _versions/vX.Y.Z ──▶ PR into main ──▶ npm + tag + GitHub Release
```

1. On your branch, run `bin/bump patch|minor|major` (or `bin/bump X.Y.Z`). It writes the version
into `package.json`, `package-lock.json` and `bin/be`. It also opens the version's section in
`CHANGELOG.md` and moves any notes under `[Unreleased]` into it. It commits, tags and publishes
nothing.
2. Describe the release under its `## [vX.Y.Z]` heading in `CHANGELOG.md`. That text becomes the
GitHub Release notes.
3. Open a PR into `main`. CI runs the tests and lint, and checks the version.
4. Once the PR is approved and merged, the Release workflow publishes the version.

A PR that doesn't touch `bin/be` (docs, tests, CI) can merge without a bump, and then releases
nothing. A PR that changes `bin/be` must bump. `scripts/install.sh` installs straight from `main`,
so whatever lands there must be a released version.

To batch several PRs into one release, collect them on a `_versions/vX.Y.Z` branch first. Bump
on that branch, where `bin/bump` defaults to `X.Y.Z`, then open one PR from it into `main`. CI
checks that the branch name and the version agree.

`dev` is retired. Nothing targets it any more.

## What CI checks

[`scripts/release/verify-version.sh`](../scripts/release/verify-version.sh) holds the rules. Both
the PR check and the release run use it.

- `bin/be`, `package.json` and `package-lock.json` carry the same version.
- A bump is newer than `main`'s version, isn't tagged or on npm yet, and has a non-empty
`CHANGELOG.md` section.
- A change to `bin/be` comes with a bump.
- A `_versions/vX.Y.Z` branch carries exactly `vX.Y.Z`.

## When a release run fails

Re-run it from the Actions tab. Each step skips what's already done (on npm, tagged), so a re-run
picks up where the failed one stopped. If a run was cancelled while it waited behind another
release, re-run that run too, because it holds its own commit.

## One-time setup

- **npm.** On npmjs.com, go to `@beplus/be` → Settings → Trusted Publisher → GitHub Actions and
enter organization `beplus`, repository `be`, workflow `release.yml`, environment `npm`. No npm
token exists anywhere: the workflow authenticates with OIDC, and npm attaches provenance to every
version. Under Publishing access, choose "Require two-factor authentication and disallow tokens".
- **GitHub.** The `npm` environment is created on the first run. Limit its deployment branches to
`main`.

`package.json` pins both `publishConfig.registry` and `publishConfig["@beplus:registry"]` to
`https://registry.npmjs.org/`. A beplus machine maps the `@beplus` scope to `npm.beplus.cloud`, and
that mapping beats a plain `registry` pin, so the scoped key is what keeps `@beplus/be` from being
published there. The same mapping also overrides `npm view --registry`. Check public npm with
`curl -s https://registry.npmjs.org/@beplus%2fbe`.
Loading
Loading