From 05003b418b3240692ba5506f204bc1c9857badf2 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Fri, 2 Oct 2026 09:40:14 +0200 Subject: [PATCH] Mirror releases to GitHub Packages Every @beplus/* repository publishes to GitHub Packages as well; be now does too. After npm, the Release workflow publishes the version there with its own GITHUB_TOKEN. The Plan job checks GitHub Packages like it checks npm and the tag, so a version missing only there (0.8.0, today) is published by the next run, from the commit its tag points at. publishConfig pins the @beplus scope to npmjs and beats a --userconfig, which is how the other repositories point npm at GitHub Packages, so the job passes --registry and --@beplus:registry on the command line. A GitHub Packages outage or access problem never holds up the npm release: it only fails its own job. Co-Authored-By: Igor Lamos --- .github/workflows/release.yml | 63 ++++++++++++++++++++++-- docs/releasing.md | 19 +++++-- scripts/release/verify-version.sh | 82 ++++++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e550357..c6208c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 }} @@ -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 }} diff --git a/docs/releasing.md b/docs/releasing.md index 5d8094f..14064c1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 @@ -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 @@ -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`. diff --git a/scripts/release/verify-version.sh b/scripts/release/verify-version.sh index b60f594..c59b6e3 100755 --- a/scripts/release/verify-version.sh +++ b/scripts/release/verify-version.sh @@ -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 # @@ -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 @@ -103,17 +115,43 @@ function remote_tag_commit() { } # -# npm_has_version — whether public npm already has that version +# registry_has_version [] — 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 — 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() { @@ -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 @@ -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}" }