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
63 changes: 59 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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.
# A merge into main that bumped the version publishes it to public npm, tags it with a GitHub
# Release, and mirrors it to GitHub Packages, as every @beplus/* repository does; 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.
# there is no npm token anywhere: the publish job authenticates with its OIDC token. GitHub Packages
# takes the workflow's own GITHUB_TOKEN.

on:
push:
Expand All @@ -29,16 +31,23 @@ jobs:
needs: ci
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read # is the version in GitHub Packages yet
outputs:
version: ${{ steps.verify.outputs.version }}
tag: ${{ steps.verify.outputs.tag }}
dist_tag: ${{ steps.verify.outputs.dist_tag }}
source_sha: ${{ steps.verify.outputs.source_sha }}
need_publish: ${{ steps.verify.outputs.need_publish }}
need_release: ${{ steps.verify.outputs.need_release }}
need_github_packages: ${{ steps.verify.outputs.need_github_packages }}
steps:
- uses: actions/checkout@v7
- id: verify
run: scripts/release/verify-version.sh release
env:
GITHUB_PACKAGES_TOKEN: ${{ github.token }}

publish:
name: Publish ${{ needs.plan.outputs.tag }}
Expand Down Expand Up @@ -100,3 +109,49 @@ jobs:
env:
TAG: ${{ needs.plan.outputs.tag }}
VERSION: ${{ needs.plan.outputs.version }}

github-packages:
name: GitHub Packages ${{ needs.plan.outputs.tag }}
needs: [plan, publish]
# After npm, so the mirror never has a version npm refused; also when there was nothing left to
# do on npm (publish skipped), which is how a version missing only here gets published.
if: >-
always() && needs.plan.result == 'success'
&& needs.plan.outputs.need_github_packages == 'true'
&& (needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# The commit the version was cut from, which is its tag once it has one: a later merge that
# bumps nothing still publishes exactly what npm has.
- uses: actions/checkout@v7
with:
ref: ${{ needs.plan.outputs.source_sha }}

- uses: actions/setup-node@v7
with:
node-version: 24

# publishConfig pins the @beplus scope to npmjs, and only command-line flags outrank it, so
# the .npmrc carries just the token, read from the environment rather than written to disk.
- name: Publish to GitHub Packages
run: |
echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' > "${RUNNER_TEMP}/.npmrc-github"
npm publish --userconfig "${RUNNER_TEMP}/.npmrc-github" \
--registry=https://npm.pkg.github.com/ --@beplus:registry=https://npm.pkg.github.com/ \
--tag "${DIST_TAG}"
env:
NODE_AUTH_TOKEN: ${{ github.token }}
DIST_TAG: ${{ needs.plan.outputs.dist_tag }}

- name: Summary
run: |
{
echo "### Mirrored ${TAG} to GitHub Packages"
echo
echo "- ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pkgs/npm/be"
} >> "${GITHUB_STEP_SUMMARY}"
env:
TAG: ${{ needs.plan.outputs.tag }}
19 changes: 16 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

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.
**public npm registry**, creates the `vX.Y.Z` tag and GitHub Release, and then mirrors the version
to **GitHub Packages**, as every `@beplus/*` repository does.

## The flow

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

1. On your branch, run `bin/bump patch|minor|major` (or `bin/bump X.Y.Z`). It writes the version
Expand Down Expand Up @@ -42,8 +44,8 @@ the PR check and the release run use it.

## 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
Re-run it from the Actions tab. Each step skips what's already done (on npm, tagged, in GitHub
Packages), 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
Expand All @@ -54,9 +56,20 @@ release, re-run that run too, because it holds its own commit.
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`.
- **GitHub Packages.** Nothing to set up: the workflow publishes with its own `GITHUB_TOKEN`, and
the first publish creates the `be` package linked to this repository. A `be` package that already
exists but isn't linked to `beplus/be` makes that publish fail with a 403. Either delete it, or
give this repository the Write role under the package's settings → Manage Actions access.

GitHub Packages is a mirror. Publishing there waits for npm, and a version that is on npm but missing
from GitHub Packages is published by the next release run, from the commit its tag points at.

`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`.

The pin beats a `--userconfig` too, which is how the other `@beplus/*` repositories point npm at
GitHub Packages. So the workflow passes `--registry` and `--@beplus:registry` on the command line,
because only command-line flags outrank `publishConfig`.
82 changes: 70 additions & 12 deletions scripts/release/verify-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@
#
# release Decides what a push to main still has to do, and writes it as key=value lines to
# $GITHUB_OUTPUT (stdout when unset): version, tag, dist_tag, need_publish (not on npm
# yet) and need_release (not tagged yet). Re-checks CHANGELOG.md when either is true.
# yet), need_release (not tagged yet), need_github_packages (not in GitHub Packages yet,
# checked only when $GITHUB_PACKAGES_TOKEN is set) and source_sha (the commit the version
# was cut from: its tag when it has one, else HEAD). Re-checks CHANGELOG.md when any of
# them has to happen.

set -euo pipefail

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

readonly NPM_REGISTRY="https://registry.npmjs.org"
readonly GITHUB_PACKAGES_REGISTRY="https://npm.pkg.github.com"
g_errors=0

#
Expand All @@ -44,6 +48,14 @@ function notice() {
fi
}

function warning() {
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
echo "::warning::$*" >&2
else
echo "Warning: $*" >&2
fi
}

function finish() {
if [[ "${g_errors}" -gt 0 ]]; then
exit 1
Expand Down Expand Up @@ -103,17 +115,43 @@ function remote_tag_commit() {
}

#
# npm_has_version <name> <version> — whether public npm already has that version
# registry_has_version <registry> <name> <version> [<token>] — whether a registry already has that
# version: returns 0 when it does, 1 when it does not, 2 when the registry gives no clear answer
#

function npm_has_version() {
local status
status="$(curl -sS -o /dev/null -w '%{http_code}' "${NPM_REGISTRY}/${1/\//%2f}/$2")"
function registry_has_version() {
local registry="$1" name="$2" version="$3" token="${4:-}" packument status
packument="$(mktemp)"
if [[ -n "${token}" ]]; then
status="$(curl -sS -o "${packument}" -w '%{http_code}' -H "Authorization: Bearer ${token}" "${registry}/${name/\//%2f}")"
else
status="$(curl -sS -o "${packument}" -w '%{http_code}' "${registry}/${name/\//%2f}")"
fi
case "${status}" in
200) return 0 ;;
404) return 1 ;;
*) echo "Error: ${NPM_REGISTRY} answered HTTP ${status} for $1@$2." >&2; exit 2 ;;
200)
node -e '
const packument = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
process.exit(packument.versions && packument.versions[process.argv[2]] ? 0 : 1);
' "${packument}" "${version}" || { rm -f "${packument}"; return 1; } ;;
404) rm -f "${packument}"; return 1 ;;
*) echo "${registry} answered HTTP ${status} for ${name}." >&2; rm -f "${packument}"; return 2 ;;
esac
rm -f "${packument}"
}

#
# npm_has_version <name> <version> — whether public npm already has that version; stops the run
# when npm gives no clear answer
#

function npm_has_version() {
local found=0
registry_has_version "${NPM_REGISTRY}" "$1" "$2" || found=$?
if [[ "${found}" -eq 2 ]]; then
echo "Error: cannot tell whether npm has $1@$2." >&2
exit 2
fi
return "${found}"
}

function check_changelog() {
Expand Down Expand Up @@ -177,23 +215,41 @@ function verify_pr() {
function verify_release() {
finish

local need_publish=false need_release=false tag_commit dist_tag=latest
local need_publish=false need_release=false need_github_packages=false tag_commit source_sha
local dist_tag=latest found=0
npm_has_version "${NAME}" "${VERSION}" || need_publish=true
tag_commit="$(remote_tag_commit "${TAG}")"
[[ -n "${tag_commit}" ]] || need_release=true
source_sha="${tag_commit:-$(git rev-parse HEAD)}"
[[ "${VERSION}" != *-* ]] || dist_tag=next

# GitHub Packages is a mirror: a registry that cannot be read must not hold up the npm release,
# so an unclear answer means "try to publish", and that job reports the actual error.
if [[ -n "${GITHUB_PACKAGES_TOKEN:-}" ]]; then
registry_has_version "${GITHUB_PACKAGES_REGISTRY}" "${NAME}" "${VERSION}" "${GITHUB_PACKAGES_TOKEN}" || found=$?
case "${found}" in
0) ;;
1) need_github_packages=true ;;
*) need_github_packages=true
warning "Cannot tell whether GitHub Packages has ${NAME}@${VERSION}, so publishing it is attempted." ;;
esac
else
notice "GITHUB_PACKAGES_TOKEN is not set, so GitHub Packages is not checked."
fi

# Publishing must not put a commit's code under a tag that names a different commit.
if [[ "${need_publish}" == "true" && -n "${tag_commit}" && "${tag_commit}" != "$(git rev-parse HEAD)" ]]; then
error "${NAME}@${VERSION} is not on npm, but tag ${TAG} points at ${tag_commit}, not HEAD. Publish from that commit or bump the version."
fi
if [[ "${need_publish}" == "true" || "${need_release}" == "true" ]]; then
if [[ "${need_publish}" == "true" || "${need_release}" == "true" || "${need_github_packages}" == "true" ]]; then
check_changelog "${TAG}"
fi
finish

if [[ "${need_publish}" == "true" || "${need_release}" == "true" ]]; then
notice "Releasing ${NAME}@${VERSION} (publish to npm: ${need_publish}, tag and GitHub Release: ${need_release})."
if [[ "${need_publish}" == "true" || "${need_release}" == "true" || "${need_github_packages}" == "true" ]]; then
notice "Releasing ${NAME}@${VERSION} from ${source_sha} (npm: ${need_publish}, tag and GitHub Release: ${need_release}, GitHub Packages: ${need_github_packages})."
elif [[ -n "${GITHUB_PACKAGES_TOKEN:-}" ]]; then
notice "Nothing to release: ${NAME}@${VERSION} is already on npm and in GitHub Packages, and tagged ${TAG}."
else
notice "Nothing to release: ${NAME}@${VERSION} is already on npm and tagged ${TAG}."
fi
Expand All @@ -202,8 +258,10 @@ function verify_release() {
echo "version=${VERSION}"
echo "tag=${TAG}"
echo "dist_tag=${dist_tag}"
echo "source_sha=${source_sha}"
echo "need_publish=${need_publish}"
echo "need_release=${need_release}"
echo "need_github_packages=${need_github_packages}"
} >> "${GITHUB_OUTPUT:-/dev/stdout}"
}

Expand Down
Loading