From 4f0d6fd79fa6e4ea1d421061c3f47a36cd45b5f1 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 15:20:38 +0100 Subject: [PATCH 01/15] chore: remove changesets leftovers Remove the changesets versioning workflow (changesets/action without a publish input, which only added a Release check to every commit on main), the generated .changeset/README.md and the root CHANGELOG.md stub. changeset publish stays the publish command. Refs #465 Co-Authored-By: Claude Opus 5.5 --- .changeset/README.md | 8 ------- .github/workflows/version.yml | 45 ----------------------------------- CHANGELOG.md | 3 --- 3 files changed, 56 deletions(-) delete mode 100644 .changeset/README.md delete mode 100644 .github/workflows/version.yml delete mode 100644 CHANGELOG.md diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index e5b6d8d6..00000000 --- a/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets) - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml deleted file mode 100644 index e003108a..00000000 --- a/.github/workflows/version.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Versioning packages - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: Setup Node.js 20.x - uses: actions/setup-node@v3 - with: - node-version: 20.x - - - name: Get node version - id: node - run: | - echo "::set-output name=version::$(node -v)" - - - name: Get node_modules cache - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package.lock') }}-${{ steps.node.outputs.version }} - - - name: Install Dependencies - run: npm i - - - name: Unset Git Hooks - run: git config --unset core.hooksPath - - - name: Create Release Pull Request - uses: changesets/action@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 5c79a3c3..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. From aab203e57f3b2bd262ea8dc1d650a7db0dbc7ea8 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 15:34:07 +0100 Subject: [PATCH 02/15] fix: add a .dockerignore for the docs image The builder stage of the docs image copies the whole build context. Send only what the build needs (allow list: workspaces, build scripts, root manifests), so that local .env* files and other local files never reach the image, and drop dependencies and build output within. Closes #463 Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..17e2b9a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Only send what the docs image build needs to the build context (allow +# list): the workspaces, the build scripts and the root manifests read by +# `turbo prune`. Everything else, such as `.env*` files with secrets, never +# reaches the image +* +!apps/ +!packages/ +!scripts/ +!package.json +!package-lock.json +!turbo.json + +# Exclude local files within the included paths: environment files (except +# examples), dependencies and build output (rebuilt in the image) +**/.env* +!**/.env.example +**/node_modules +**/.next +**/.turbo +**/.vercel +**/coverage +**/dist +**/out From cfeb90e369e3683ca86012a1cd05f888737039ba Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 15:35:11 +0100 Subject: [PATCH 03/15] ci: pin actions and fix caches Pin every action to a full commit SHA on its current major version (checkout v7, setup-node v7, cache v6, semantic PR title v6, ssh-agent v0.10, push action v1.3.0 instead of master), with Dependabot keeping the pins up to date. Replace the node_modules caches, whose key hashed a file that doesn't exist and which used ::set-output, by the npm cache of setup-node in CI; releases use no dependency cache. Drop the force-with-lease input of the push action, which doesn't exist (the input is force_with_lease) and so never had an effect. Refs #465 Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 11 +++++++ .github/workflows/pr_evaluation.yml | 42 ++++++-------------------- .github/workflows/publish.yml | 47 +++++++++++++---------------- 3 files changed, 41 insertions(+), 59 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7ffb1898 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Actions in the workflows are pinned to commit SHAs; this keeps the pins + # (and their version comments) up to date, in one grouped pull request + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/pr_evaluation.yml b/.github/workflows/pr_evaluation.yml index de5cef4c..2121f48c 100644 --- a/.github/workflows/pr_evaluation.yml +++ b/.github/workflows/pr_evaluation.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event.action != 'edited' || github.event.changes.title != null }} steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest needs: semantic_pr steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: List files with merge conflict markers run: git --no-pager grep "<<<<<<<" ":(exclude).github/" || true - name: Fail or succeed job if any files with merge conflict markers have been checked in @@ -61,27 +61,15 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x - - - name: Get node version - id: node - run: | - echo "::set-output name=version::$(node -v)" - - - name: Get node_modules cache - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package.lock') }}-${{ steps.node.outputs.version }} + cache: npm - name: Install dependencies run: npm install @@ -99,31 +87,19 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} - - - name: Get node version - id: node - run: | - echo "::set-output name=version::$(node -v)" - - - name: Get node_modules cache - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package.lock') }}-${{ steps.node.outputs.version }} + cache: npm - name: Cache Playwright id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/ms-playwright diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d311d89f..53116636 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,27 +13,17 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x - - - name: Get node version - id: node - run: | - echo "::set-output name=version::$(node -v)" - - - name: Get node_modules cache - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package.lock') }}-${{ steps.node.outputs.version }} + # No dependency cache in releases (cache poisoning); `package.json` + # sets `packageManager`, which would turn on npm caching by default + package-manager-cache: false - name: Install dependencies run: npm install @@ -47,12 +37,12 @@ jobs: # steps: # - name: Check out code - # uses: actions/checkout@v3 + # uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # with: # fetch-depth: 2 # - name: Use Node.js 20.x - # uses: actions/setup-node@v3 + # uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 # with: # node-version: 20.x @@ -62,7 +52,7 @@ jobs: # echo "::set-output name=version::$(node -v)" # - name: Get node_modules cache - # uses: actions/cache@v4 + # uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # id: node_modules # with: # path: | @@ -89,7 +79,7 @@ jobs: needs: - linters steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - name: Remove 'v' from RELEASE_VERSION @@ -99,12 +89,12 @@ jobs: echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV shell: bash - name: Install SSH Client - uses: webfactory/ssh-agent@v0.7.0 + uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main @@ -114,9 +104,12 @@ jobs: git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x + # No dependency cache in releases (cache poisoning); `package.json` + # sets `packageManager`, which would turn on npm caching by default + package-manager-cache: false - name: Install dependencies run: npm ci @@ -183,9 +176,8 @@ jobs: git commit -m "bump: v$RELEASE_VERSION" - name: Push changes - uses: ad-m/github-push-action@master + uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # v1.3.0 with: - force-with-lease: true ssh: true branch: main @@ -195,7 +187,7 @@ jobs: needs: bump-monorepo steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main @@ -210,9 +202,12 @@ jobs: shell: bash - name: Setup Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x + # No dependency cache in releases (cache poisoning); `package.json` + # sets `packageManager`, which would turn on npm caching by default + package-manager-cache: false - name: Login to npm run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc From 51263de249a3a671ee4d5ebda774779f1ee32ae5 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 16:15:46 +0100 Subject: [PATCH 04/15] ci: prefix Dependabot titles, tidy comments Give Dependabot pull requests a ci: prefix, so that their titles pass the PR title check. Keep the commented-out release tests job as it was and state the release cache comment once. Refs #465 Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 3 +++ .github/workflows/publish.yml | 12 +++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7ffb1898..44066b76 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,9 @@ updates: # (and their version comments) up to date, in one grouped pull request - package-ecosystem: github-actions directory: / + # Pull request titles must be Conventional Commits (PR title check) + commit-message: + prefix: ci schedule: interval: monthly groups: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53116636..b695d5b6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,12 +37,12 @@ jobs: # steps: # - name: Check out code - # uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # uses: actions/checkout@v3 # with: # fetch-depth: 2 # - name: Use Node.js 20.x - # uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + # uses: actions/setup-node@v3 # with: # node-version: 20.x @@ -52,7 +52,7 @@ jobs: # echo "::set-output name=version::$(node -v)" # - name: Get node_modules cache - # uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # uses: actions/cache@v4 # id: node_modules # with: # path: | @@ -107,8 +107,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x - # No dependency cache in releases (cache poisoning); `package.json` - # sets `packageManager`, which would turn on npm caching by default + # No dependency cache in releases, see above package-manager-cache: false - name: Install dependencies @@ -205,8 +204,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20.x - # No dependency cache in releases (cache poisoning); `package.json` - # sets `packageManager`, which would turn on npm caching by default + # No dependency cache in releases, see above package-manager-cache: false - name: Login to npm From 78891fef9618ee99dc49bfe75bc8d232464a5875 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 16:56:02 +0100 Subject: [PATCH 05/15] ci: run tests on Ubuntu 24.04 ubuntu-latest now is Ubuntu 26.04, on which Playwright 1.56 can't install its browsers, so every test job fails (unrelated to the other changes). Pin the test job to ubuntu-24.04 until Playwright is upgraded. Refs #465 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr_evaluation.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_evaluation.yml b/.github/workflows/pr_evaluation.yml index 2121f48c..ac026971 100644 --- a/.github/workflows/pr_evaluation.yml +++ b/.github/workflows/pr_evaluation.yml @@ -79,7 +79,9 @@ jobs: tests: name: Test checks - runs-on: ubuntu-latest + # Playwright 1.56 can't install its browsers on Ubuntu 26.04, which + # `ubuntu-latest` now is; move on when Playwright is upgraded + runs-on: ubuntu-24.04 needs: semantic_pr strategy: matrix: From b989ff05dc82097c4d36ada9bf0f37cba48ae7c6 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 17:27:03 +0100 Subject: [PATCH 06/15] build: move CI and Docker to Node.js 22 and 24 Node.js 18 and 20 are end of life. Test on 22 and 24, lint and release on 24 (npm 11, needed for trusted publishing), build the docs image on node:24-alpine and update the development prerequisites in the README. engines and packageManager stay as they are. Closes #466 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr_evaluation.yml | 6 +++--- .github/workflows/publish.yml | 10 +++++----- Dockerfile | 2 +- README.md | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr_evaluation.yml b/.github/workflows/pr_evaluation.yml index ac026971..58938361 100644 --- a/.github/workflows/pr_evaluation.yml +++ b/.github/workflows/pr_evaluation.yml @@ -65,10 +65,10 @@ jobs: with: fetch-depth: 2 - - name: Use Node.js 20.x + - name: Use Node.js 24.x uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20.x + node-version: 24.x cache: npm - name: Install dependencies @@ -85,7 +85,7 @@ jobs: needs: semantic_pr strategy: matrix: - node-version: [18.x, 20.x] + node-version: [22.x, 24.x] steps: - name: Check out code diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b695d5b6..0f745e89 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,10 +17,10 @@ jobs: with: fetch-depth: 2 - - name: Use Node.js 20.x + - name: Use Node.js 24.x uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20.x + node-version: 24.x # No dependency cache in releases (cache poisoning); `package.json` # sets `packageManager`, which would turn on npm caching by default package-manager-cache: false @@ -106,7 +106,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20.x + node-version: 24.x # No dependency cache in releases, see above package-manager-cache: false @@ -200,10 +200,10 @@ jobs: echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV shell: bash - - name: Setup Node.js 20.x + - name: Setup Node.js 24.x uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20.x + node-version: 24.x # No dependency cache in releases, see above package-manager-cache: false diff --git a/Dockerfile b/Dockerfile index f572bbe7..4f317859 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use an official Node.js runtime as the base image -FROM node:20-alpine AS base +FROM node:24-alpine AS base FROM base AS builder RUN apk update diff --git a/README.md b/README.md index eca1028a..d7d658d9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For comprehensive guides, API references, and examples, visit our documentation: ### Prerequisites -- Node.js (v18 or higher) +- Node.js 22 or later (CI tests 22 and 24) - npm or yarn ### Development Setup From d86a39622791ed63a9bbc75fdc9527f1e4b31b7e Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 17:35:47 +0100 Subject: [PATCH 07/15] ci: publish with npm trusted publishing Rename publish.yml to release.yml (Release), as in the organization's other repositories, with job names Lint release, Bump version, Publish packages. The publish job gets id-token: write (and only it), checks for npm 11.5.1 or later and publishes through OIDC with provenance. NPM_TOKEN stays as a fallback: npm tries OIDC first and uses the token only if the exchange fails; remove it after the first OIDC release. A new job creates a GitHub release with generated notes from the previous version tag, as a prerelease for versions with a suffix. Closes #467 Co-Authored-By: Claude Opus 5.5 --- .../workflows/{publish.yml => release.yml} | 87 ++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) rename .github/workflows/{publish.yml => release.yml} (76%) diff --git a/.github/workflows/publish.yml b/.github/workflows/release.yml similarity index 76% rename from .github/workflows/publish.yml rename to .github/workflows/release.yml index 0f745e89..2c49a298 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,24 @@ -name: Publish packages +# Releases from version tags: a maintainer pushes `v` on `main` (see +# "Releases" in CONTRIBUTING.md). The workflow lints, sets all released +# packages to the version, pushes `bump: v` to `main`, publishes the +# packages to npm with trusted publishing (OIDC, with provenance) and creates +# a GitHub release for the tag. The trusted publishers on npmjs.com name this +# file (`release.yml`): keep the name + +name: Release on: push: tags: - 'v*' +permissions: + contents: read + jobs: - linters: - name: Linting checks + lint: + name: Lint release runs-on: ubuntu-24.04 steps: @@ -73,11 +83,11 @@ jobs: # - name: Run tests # run: npm run test - bump-monorepo: - name: Bump monorepo + bump: + name: Bump version runs-on: ubuntu-latest needs: - - linters + - lint steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set env @@ -181,9 +191,12 @@ jobs: branch: main publish: - name: Publish on npm + name: Publish packages runs-on: ubuntu-latest - needs: bump-monorepo + needs: bump + permissions: + contents: read + id-token: write # npm trusted publishing (OIDC) and provenance steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -207,8 +220,29 @@ jobs: # No dependency cache in releases, see above package-manager-cache: false - - name: Login to npm - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc + # Trusted publishing needs npm 11.5.1 or later (Node.js 24 has it) + - name: Check npm version + run: | + version="$(npm --version)" + if ! node -e ' + const [major, minor, patch] = process.argv[1].split(".").map(Number); + process.exit(major > 11 || (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))) ? 0 : 1); + ' "${version}"; then + echo "::error::npm ${version} can't publish with trusted publishing (needs 11.5.1 or later)" + exit 1 + fi + + # Fallback until the first release with trusted publishing has worked: + # npm tries OIDC first and uses this token only if that fails (e.g. no + # trusted publisher configured for a package). Then remove this step + # and revoke the `NPM_TOKEN` secret + - name: Configure npm token (fallback) + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "${NPM_TOKEN}" ]; then + printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > ~/.npmrc + fi - name: Install Dependencies run: npm ci @@ -289,3 +323,36 @@ jobs: - name: Publish to npm run: npm run release + + # A GitHub release for the tag, with notes generated from the previous + # version tag on (a prerelease for versions such as `2.0.0-alpha.49`). + # Skipped if the release exists already, so that the job can be re-run + github: + name: GitHub release + runs-on: ubuntu-latest + needs: publish + permissions: + contents: write # create the release + steps: + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "${TAG}" --repo "${REPOSITORY}" > /dev/null 2>&1; then + echo "::notice::Release ${TAG} exists already" + exit 0 + fi + args=(--repo "${REPOSITORY}" --verify-tag --title "${TAG}" --generate-notes) + # The version tag before this one, by version order + previous="$(gh api --paginate "repos/${REPOSITORY}/tags" --jq '.[].name' \ + | grep -E '^v[0-9]' | sort -V \ + | awk -v tag="${TAG}" '$0 == tag { print prev; exit } { prev = $0 }')" + if [ -n "${previous}" ]; then + args+=(--notes-start-tag "${previous}") + fi + if [[ "${TAG}" == *-* ]]; then + args+=(--prerelease) + fi + gh release create "${TAG}" "${args[@]}" From 80f0e31322d484ef288981b1ab700d02a1372ef7 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Mon, 26 Oct 2026 18:31:49 +0100 Subject: [PATCH 08/15] ci: fix release notes range, check provenance Order version tags by semver when looking up the previous tag for the release notes (sort -V puts v2.0.0 before v2.0.0-alpha.1). Configure the NPM_TOKEN fallback only right before publishing, say in the log when it is configured, and warn about released versions without provenance (published with the token). Name the jobs Release packages and Release on GitHub. Refs #467 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 49 +++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c49a298..ff1c020f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,7 +191,7 @@ jobs: branch: main publish: - name: Publish packages + name: Release packages runs-on: ubuntu-latest needs: bump permissions: @@ -232,18 +232,6 @@ jobs: exit 1 fi - # Fallback until the first release with trusted publishing has worked: - # npm tries OIDC first and uses this token only if that fails (e.g. no - # trusted publisher configured for a package). Then remove this step - # and revoke the `NPM_TOKEN` secret - - name: Configure npm token (fallback) - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - if [ -n "${NPM_TOKEN}" ]; then - printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > ~/.npmrc - fi - - name: Install Dependencies run: npm ci @@ -321,14 +309,41 @@ jobs: - name: Build Packages run: npm run build + # Fallback until the first release with trusted publishing has worked + # (configured only right before publishing, not during install and + # build): + # npm tries OIDC first and uses this token only if that fails (e.g. no + # trusted publisher configured for a package). Then remove this step + # and revoke the `NPM_TOKEN` secret + - name: Configure npm token (fallback) + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "${NPM_TOKEN}" ]; then + echo "::notice::Using the NPM_TOKEN fallback for packages without a trusted publisher" + printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > ~/.npmrc + fi + - name: Publish to npm run: npm run release + # Trusted publishing adds provenance; a version without it was published + # with the `NPM_TOKEN` fallback (check this before removing the token) + - name: Check provenance + run: | + for package_file in packages/*/package.json; do + name="$(node -p "const p = require('./${package_file}'); p.private || p.version !== '${RELEASE_VERSION}' ? '' : p.name")" + [ -n "${name}" ] || continue + if [ -z "$(npm view "${name}@${RELEASE_VERSION}" dist.attestations.provenance 2> /dev/null)" ]; then + echo "::warning::${name}@${RELEASE_VERSION} has no provenance: published with the NPM_TOKEN fallback, or not visible on npm yet" + fi + done + # A GitHub release for the tag, with notes generated from the previous # version tag on (a prerelease for versions such as `2.0.0-alpha.49`). # Skipped if the release exists already, so that the job can be re-run github: - name: GitHub release + name: Release on GitHub runs-on: ubuntu-latest needs: publish permissions: @@ -345,9 +360,11 @@ jobs: exit 0 fi args=(--repo "${REPOSITORY}" --verify-tag --title "${TAG}" --generate-notes) - # The version tag before this one, by version order + # The version tag before this one, in semver order: `sort -V` puts + # `v2.0.0` before `v2.0.0-alpha.1`, so versions without a suffix get + # a temporary `_`, which sorts after `-` previous="$(gh api --paginate "repos/${REPOSITORY}/tags" --jq '.[].name' \ - | grep -E '^v[0-9]' | sort -V \ + | grep -E '^v[0-9]' | sed '/-/!s/$/_/' | sort -V | sed 's/_$//' \ | awk -v tag="${TAG}" '$0 == tag { print prev; exit } { prev = $0 }')" if [ -n "${previous}" ]; then args+=(--notes-start-tag "${previous}") From 70009a049fc2210c4aacfa6f0f4a144e966ea842 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 02:28:15 +0100 Subject: [PATCH 09/15] build: release all public packages together Add scripts/release.js, which sets and checks the shared version of the root and of all public packages in packages/*, prints their dist-tag and pins their internal dependencies for publishing (with tests). @elixir-cloud/tes is deprecated (no build, so a release would have no code): mark it private, so that it is no longer released. Give @elixir-cloud/eslint-config a repository URL for provenance. Drop the unused `release` script (changeset publish). Co-Authored-By: Claude Opus 5.5 --- package.json | 1 - packages/ecc-client-ga4gh-tes/package.json | 1 + packages/eslint-config-elixir/package.json | 4 + scripts/release.js | 186 +++++++++++++++++++++ scripts/release.test.js | 130 ++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 scripts/release.js create mode 100644 scripts/release.test.js diff --git a/package.json b/package.json index 8e52e987..64b5c6f2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "lint:fix": "eslint . --fix && turbo run lint:fix && prettier .", "new": "turbo gen", "prepare": "husky install", - "release": "changeset publish", "test": "turbo run test --parallel", "test:watch": "turbo run test --watch" }, diff --git a/packages/ecc-client-ga4gh-tes/package.json b/packages/ecc-client-ga4gh-tes/package.json index ccb9e1d9..d5effdae 100644 --- a/packages/ecc-client-ga4gh-tes/package.json +++ b/packages/ecc-client-ga4gh-tes/package.json @@ -3,6 +3,7 @@ "description": "Web Component for interacting with TES API", "license": "Apache-2.0", "version": "1.0.1", + "private": true, "type": "module", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/eslint-config-elixir/package.json b/packages/eslint-config-elixir/package.json index b462df2e..81fa2cc0 100644 --- a/packages/eslint-config-elixir/package.json +++ b/packages/eslint-config-elixir/package.json @@ -9,6 +9,10 @@ }, "author": "", "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/elixir-cloud-aai/cloud-components.git" + }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 00000000..5ade0f12 --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Release helper (see "Releases" in CONTRIBUTING.md). All public packages in +// `packages/*` (those without `"private": true`) share the version of the +// root `package.json` and are released together. +// +// Usage: +// node scripts/release.js set set the version of the root and +// of all released packages (for +// the version bump pull request) +// node scripts/release.js check fail unless the root and all +// released packages have +// node scripts/release.js packages print the directories of the +// released packages, one per line +// node scripts/release.js dist-tag +// print the npm dist-tag for +// : `latest`, or the +// prerelease identifier (`alpha` +// for `2.0.0-alpha.49`) +// node scripts/release.js pin set the `@elixir-cloud/*` +// dependencies and peer +// dependencies of the released +// packages to (in the +// release workflow only, before +// building; not committed) + +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const scope = "@elixir-cloud/"; + +// Semantic Versioning 2.0.0, without build metadata +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/; + +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +const writeJson = (file, data) => + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); + +const parseVersion = (version) => { + const match = semverPattern.exec(version || ""); + if (!match) { + throw new Error(`Not a version (e.g. 2.0.0-alpha.49): '${version}'`); + } + return { prerelease: match[4] ? match[4].split(".") : [] }; +}; + +// Public packages in `packages/*`, sorted by directory +const releasedPackages = (base = root) => { + const packagesDir = path.join(base, "packages"); + return fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join("packages", entry.name)) + .filter((dir) => fs.existsSync(path.join(base, dir, "package.json"))) + .map((dir) => ({ + dir, + manifest: readJson(path.join(base, dir, "package.json")), + })) + .filter(({ manifest }) => !manifest.private) + .sort((a, b) => a.dir.localeCompare(b.dir)); +}; + +// Messages for the root and the released packages whose version isn't +// `version` +const versionMismatches = (version, base = root) => { + parseVersion(version); + const manifests = [ + { dir: ".", manifest: readJson(path.join(base, "package.json")) }, + ...releasedPackages(base), + ]; + return manifests + .filter(({ manifest }) => manifest.version !== version) + .map( + ({ dir, manifest }) => + `${manifest.name} (${dir}) has version ${manifest.version}, not ${version}` + ); +}; + +// `latest` for releases, the first prerelease identifier otherwise (or +// `next`, if it is a number) +const distTag = (version) => { + const { prerelease } = parseVersion(version); + if (prerelease.length === 0) { + return "latest"; + } + return /^[a-zA-Z][0-9a-zA-Z-]*$/.test(prerelease[0]) && + prerelease[0] !== "latest" + ? prerelease[0] + : "next"; +}; + +// Pins the dependencies on released packages; returns the changes made +const pinInternalDependencies = (version, base = root) => { + parseVersion(version); + const packages = releasedPackages(base); + const names = new Set(packages.map(({ manifest }) => manifest.name)); + const changes = []; + packages.forEach(({ dir, manifest }) => { + let changed = false; + ["dependencies", "peerDependencies"].forEach((section) => { + Object.keys(manifest[section] || {}) + .filter((name) => name.startsWith(scope) && names.has(name)) + .forEach((name) => { + if (manifest[section][name] !== version) { + changes.push( + `${manifest.name}: ${section}.${name} ${manifest[section][name]} -> ${version}` + ); + manifest[section][name] = version; + changed = true; + } + }); + }); + if (changed) { + writeJson(path.join(base, dir, "package.json"), manifest); + } + }); + return changes; +}; + +// Sets the version with `npm version`, which also updates the lock file +const setVersion = (version) => { + parseVersion(version); + const workspaces = releasedPackages().flatMap(({ dir }) => [ + "--workspace", + dir, + ]); + execFileSync( + "npm", + [ + "version", + version, + "--no-git-tag-version", + "--include-workspace-root", + ...workspaces, + ], + { cwd: root, stdio: "inherit" } + ); +}; + +const main = (args) => { + const [command, version] = args; + switch (command) { + case "set": + setVersion(version); + return 0; + case "check": { + const mismatches = versionMismatches(version); + mismatches.forEach((message) => console.error(message)); + return mismatches.length === 0 ? 0 : 1; + } + case "packages": + releasedPackages().forEach(({ dir }) => console.log(dir)); + return 0; + case "dist-tag": + console.log(distTag(version)); + return 0; + case "pin": + pinInternalDependencies(version).forEach((change) => console.log(change)); + return 0; + default: + console.error( + "Usage: node scripts/release.js set|check|packages|dist-tag|pin []" + ); + return 2; + } +}; + +if (require.main === module) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + distTag, + parseVersion, + pinInternalDependencies, + releasedPackages, + versionMismatches, +}; diff --git a/scripts/release.test.js b/scripts/release.test.js new file mode 100644 index 00000000..32faac02 --- /dev/null +++ b/scripts/release.test.js @@ -0,0 +1,130 @@ +// Tests for scripts/release.js; run with `node --test scripts/release.test.js` + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { test } = require("node:test"); + +const { + distTag, + parseVersion, + pinInternalDependencies, + releasedPackages, + versionMismatches, +} = require("./release.js"); + +const writeJson = (file, data) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); +}; + +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +// A repository with a root, two public packages, a private one and a +// directory without `package.json` +const fixture = (version = "2.0.0-alpha.49") => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "release-test-")); + writeJson(path.join(base, "package.json"), { name: "root", version }); + writeJson(path.join(base, "packages", "b-design", "package.json"), { + name: "@elixir-cloud/design", + version, + devDependencies: { "@elixir-cloud/eslint-config": "*" }, + }); + writeJson(path.join(base, "packages", "a-wes", "package.json"), { + name: "@elixir-cloud/wes", + version, + dependencies: { + "@elixir-cloud/design": "*", + "@elixir-cloud/ro-crate": "*", + lit: "^2.8.0", + }, + peerDependencies: { "@elixir-cloud/design": "*" }, + }); + writeJson(path.join(base, "packages", "c-ro-crate", "package.json"), { + name: "@elixir-cloud/ro-crate", + version: "0.1.0", + private: true, + }); + fs.mkdirSync(path.join(base, "packages", "d-empty")); + return base; +}; + +test("parseVersion accepts versions and rejects other strings", () => { + assert.deepStrictEqual(parseVersion("2.0.0").prerelease, []); + assert.deepStrictEqual(parseVersion("2.0.0-alpha.49").prerelease, [ + "alpha", + "49", + ]); + [ + "", + "v2.0.0", + "2.0", + "2.0.0-", + "02.0.0", + "2.0.0+build", + "2.0.0-alpha..1", + undefined, + ].forEach((version) => + assert.throws(() => parseVersion(version), /Not a version/) + ); +}); + +test("distTag uses latest for releases and the prerelease identifier", () => { + assert.strictEqual(distTag("2.0.0"), "latest"); + assert.strictEqual(distTag("2.0.0-alpha.49"), "alpha"); + assert.strictEqual(distTag("2.1.0-rc.1"), "rc"); + assert.strictEqual(distTag("2.1.0-1"), "next"); + assert.strictEqual(distTag("2.1.0-latest.1"), "next"); +}); + +test("releasedPackages lists public packages, sorted by directory", () => { + const base = fixture(); + assert.deepStrictEqual( + releasedPackages(base).map(({ dir }) => dir), + [path.join("packages", "a-wes"), path.join("packages", "b-design")] + ); +}); + +test("versionMismatches is empty when all versions match", () => { + assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", fixture()), []); +}); + +test("versionMismatches names the root and packages with other versions", () => { + const base = fixture(); + const design = path.join(base, "packages", "b-design", "package.json"); + writeJson(design, { ...readJson(design), version: "2.0.0-alpha.48" }); + assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", base), [ + `@elixir-cloud/design (${path.join( + "packages", + "b-design" + )}) has version 2.0.0-alpha.48, not 2.0.0-alpha.49`, + ]); + assert.strictEqual(versionMismatches("2.0.0-alpha.50", base).length, 3); + assert.throws( + () => versionMismatches("v2.0.0-alpha.49", base), + /Not a version/ + ); +}); + +test("pinInternalDependencies pins dependencies on released packages only", () => { + const base = fixture(); + const changes = pinInternalDependencies("2.0.0-alpha.49", base); + assert.strictEqual(changes.length, 2); + const wes = readJson(path.join(base, "packages", "a-wes", "package.json")); + assert.deepStrictEqual(wes.dependencies, { + "@elixir-cloud/design": "2.0.0-alpha.49", + "@elixir-cloud/ro-crate": "*", + lit: "^2.8.0", + }); + assert.deepStrictEqual(wes.peerDependencies, { + "@elixir-cloud/design": "2.0.0-alpha.49", + }); + const design = readJson( + path.join(base, "packages", "b-design", "package.json") + ); + assert.deepStrictEqual(design.devDependencies, { + "@elixir-cloud/eslint-config": "*", + }); + assert.deepStrictEqual(pinInternalDependencies("2.0.0-alpha.49", base), []); +}); From c53306396ef7d688d6a043380fc5dcb69e96f541 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 02:28:31 +0100 Subject: [PATCH 10/15] ci: stage releases from tags with npm Rework release.yml for the branch model of the other repositories: the version bump is a pull request to `dev`, a maintainer tags `v` on `dev`, and the workflow builds from the tag and pushes nothing (no bump job, no deploy key). It checks that all package versions match the tag and that the tag is on the default branch before anything else. The publish job runs in the `npm` environment and stages each public package with `npm stage publish --provenance --tag ` (trusted publishing; npm 11.15.0 or later); maintainers approve the staged versions on npmjs.com. The NPM_TOKEN fallback and the provenance check are gone. Outside the main repository, staging is a dry run. PR Evaluation now runs on pull requests to `dev`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr_evaluation.yml | 2 +- .github/workflows/release.yml | 401 +++++++++------------------- 2 files changed, 129 insertions(+), 274 deletions(-) diff --git a/.github/workflows/pr_evaluation.yml b/.github/workflows/pr_evaluation.yml index 58938361..1c361c29 100644 --- a/.github/workflows/pr_evaluation.yml +++ b/.github/workflows/pr_evaluation.yml @@ -3,7 +3,7 @@ name: PR Evaluation on: pull_request: types: [opened, synchronize, reopened, ready_for_review, edited] - branches: ['main'] + branches: ['dev'] jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff1c020f..39717e2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,14 @@ -# Releases from version tags: a maintainer pushes `v` on `main` (see -# "Releases" in CONTRIBUTING.md). The workflow lints, sets all released -# packages to the version, pushes `bump: v` to `main`, publishes the -# packages to npm with trusted publishing (OIDC, with provenance) and creates -# a GitHub release for the tag. The trusted publishers on npmjs.com name this +# Releases from version tags (see "Releases" in CONTRIBUTING.md): after the +# version bump pull request, a maintainer pushes `v` on `dev`. The +# workflow checks the tag, lints, builds the packages from the tag, stages +# them on npm with trusted publishing (OIDC, with provenance) once the `npm` +# environment is approved, and creates a GitHub release for the tag. Staged +# versions become public only when a maintainer approves them on npmjs.com. +# The workflow pushes nothing. The trusted publishers on npmjs.com name this # file (`release.yml`): keep the name +# +# Outside elixir-cloud-aai/cloud-components (e.g. in a fork, for a dry run), +# `npm stage publish` runs with `--dry-run` name: Release @@ -15,336 +20,186 @@ on: permissions: contents: read +# One release at a time; never cancel a running release +concurrency: + group: release + cancel-in-progress: false + jobs: - lint: - name: Lint release + readiness: + name: Release readiness runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + dist-tag: ${{ steps.version.outputs.dist-tag }} steps: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 2 - - - name: Use Node.js 24.x - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24.x - # No dependency cache in releases (cache poisoning); `package.json` - # sets `packageManager`, which would turn on npm caching by default - package-manager-cache: false + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false - - name: Install dependencies - run: npm install - - - name: Run linters - run: npm run lint - - # tests: - # name: Test checks - # runs-on: ubuntu-22.04 - - # steps: - # - name: Check out code - # uses: actions/checkout@v3 - # with: - # fetch-depth: 2 - - # - name: Use Node.js 20.x - # uses: actions/setup-node@v3 - # with: - # node-version: 20.x - - # - name: Get node version - # id: node - # run: | - # echo "::set-output name=version::$(node -v)" - - # - name: Get node_modules cache - # uses: actions/cache@v4 - # id: node_modules - # with: - # path: | - # **/node_modules - # key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package.lock') }}-${{ steps.node.outputs.version }} - - # - name: Install dependencies - # run: npm ci - - # - name: Install playwright browsers - # run: | - # sudo apt update - # npx playwright install --with-deps + - name: Check the version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + version="${TAG#v}" + if ! node scripts/release.js check "${version}"; then + echo "::error::Not all package versions are ${version}: merge the version bump pull request before tagging" + exit 1 + fi + { + echo "version=${version}" + echo "dist-tag=$(node scripts/release.js dist-tag "${version}")" + } >> "${GITHUB_OUTPUT}" - # - name: Build - # run: npm run build + - name: Check the branch + env: + BRANCH: ${{ github.event.repository.default_branch }} + run: | + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${BRANCH}"; then + echo "::error::The tag is not on ${BRANCH}" + exit 1 + fi - # - name: Run tests - # run: npm run test + lint: + name: Lint release + runs-on: ubuntu-24.04 + needs: readiness - bump: - name: Bump version - runs-on: ubuntu-latest - needs: - - lint steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set env - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Remove 'v' from RELEASE_VERSION - run: | - echo "Original RELEASE_VERSION: $RELEASE_VERSION" - RELEASE_VERSION=$(echo "$RELEASE_VERSION" | sed 's/^v//') - echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV - shell: bash - - name: Install SSH Client - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - with: - ssh-private-key: ${{ secrets.DEPLOY_KEY }} - - - name: Checkout + - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.ref }} + persist-credentials: false - - name: Git user config - run: | - git config --global user.name "GitHub Actions" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Setup Node.js + - name: Use Node.js 24.x uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x - # No dependency cache in releases, see above + # No dependency cache in releases (cache poisoning); `package.json` + # sets `packageManager`, which would turn on npm caching by default package-manager-cache: false - name: Install dependencies run: npm ci - - name: Update root package version - run: npm version $RELEASE_VERSION --no-git-tag-version - - - name: Update all package versions - run: | - # Custom ignore list (includes eslint-config which we don't want to bump) - IGNORE_PACKAGES="ecc-docs,@elixir-cloud/ro-crate,@elixir-cloud/tes,@elixir-cloud/eslint-config" - - echo "Ignore list: $IGNORE_PACKAGES" - - # Find all package.json files and update versions, excluding all node_modules directories - find . -name "package.json" \ - -not -path "*/node_modules/*" \ - -not -path "./.git/*" \ - -not -path "./.changeset/*" \ - -not -path "./.github/*" \ - -not -path "./.husky/*" \ - -not -path "./.turbo/*" | while read package_file; do - - if [ "$package_file" != "./package.json" ]; then - # Extract package name from package.json - PACKAGE_NAME=$(node -e " - try { - const pkg = require('$package_file'); - console.log(pkg.name || ''); - } catch(e) { - console.log(''); - } - ") - - # Check if package should be ignored - if [ -n "$PACKAGE_NAME" ]; then - SHOULD_IGNORE=$(echo "$IGNORE_PACKAGES" | grep -q "$PACKAGE_NAME" && echo "true" || echo "false") - - if [ "$SHOULD_IGNORE" = "false" ]; then - echo "Updating $PACKAGE_NAME in $package_file to version $RELEASE_VERSION" - # Update version in package.json - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('$package_file', 'utf8')); - if (!pkg.private) { - pkg.version = '$RELEASE_VERSION'; - fs.writeFileSync('$package_file', JSON.stringify(pkg, null, 2) + '\n'); - } - " - else - echo "Skipping $PACKAGE_NAME (in ignore list)" - fi - fi - fi - done - - - name: ReInstall Dependencies - run: | - npm install - - - name: Commit version - run: | - git add . - git commit -m "bump: v$RELEASE_VERSION" - - - name: Push changes - uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # v1.3.0 - with: - ssh: true - branch: main + - name: Run linters + run: npm run lint publish: name: Release packages - runs-on: ubuntu-latest - needs: bump + runs-on: ubuntu-24.04 + needs: + - readiness + - lint + environment: npm permissions: contents: read id-token: write # npm trusted publishing (OIDC) and provenance + env: + VERSION: ${{ needs.readiness.outputs.version }} + DIST_TAG: ${{ needs.readiness.outputs.dist-tag }} + # Stage for real only in the main repository + DRY_RUN: ${{ github.repository != 'elixir-cloud-aai/cloud-components' }} + steps: - - name: Checkout Repo + - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.ref }} + persist-credentials: false - - name: Set env - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - - name: Remove 'v' from RELEASE_VERSION - run: | - echo "Original RELEASE_VERSION: $RELEASE_VERSION" - RELEASE_VERSION=$(echo "$RELEASE_VERSION" | sed 's/^v//') - echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV - shell: bash - - - name: Setup Node.js 24.x + - name: Use Node.js 24.x uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.x + # The runner's cached Node.js 24 may bundle an npm without `stage` + check-latest: true # No dependency cache in releases, see above package-manager-cache: false - # Trusted publishing needs npm 11.5.1 or later (Node.js 24 has it) + # `npm stage publish` needs npm 11.15.0 or later - name: Check npm version run: | version="$(npm --version)" if ! node -e ' - const [major, minor, patch] = process.argv[1].split(".").map(Number); - process.exit(major > 11 || (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))) ? 0 : 1); + const [major, minor] = process.argv[1].split(".").map(Number); + process.exit(major > 11 || (major === 11 && minor >= 15) ? 0 : 1); ' "${version}"; then - echo "::error::npm ${version} can't publish with trusted publishing (needs 11.5.1 or later)" + echo "::error::npm ${version} has no 'npm stage publish' (needs 11.15.0 or later)" exit 1 fi - - name: Install Dependencies + - name: Install dependencies run: npm ci - - name: Update internal dependencies - run: | - # Custom ignore list (includes eslint-config which we don't want to update dependencies for) - IGNORE_PACKAGES="ecc-docs,@elixir-cloud/ro-crate,@elixir-cloud/tes,@elixir-cloud/eslint-config" - - echo "Ignore list: $IGNORE_PACKAGES" - echo "Updating internal @elixir-cloud dependencies to version $RELEASE_VERSION" - - # Only process package.json files in workspace directories (apps/* and packages/*) - for workspace_dir in apps packages; do - if [ -d "$workspace_dir" ]; then - find "$workspace_dir" -maxdepth 2 -name "package.json" -not -path "*/node_modules/*" | while read package_file; do - # Extract package name from package.json - PACKAGE_NAME=$(node -e " - try { - const pkg = require('./$package_file'); - console.log(pkg.name || ''); - } catch(e) { - console.log(''); - } - ") - - # Check if package should be processed (not in ignore list and starts with @elixir-cloud) - if [ -n "$PACKAGE_NAME" ]; then - SHOULD_IGNORE=$(echo "$IGNORE_PACKAGES" | grep -q "$PACKAGE_NAME" && echo "true" || echo "false") - IS_ELIXIR_CLOUD=$(echo "$PACKAGE_NAME" | grep -q "^@elixir-cloud/" && echo "true" || echo "false") - - if [ "$SHOULD_IGNORE" = "false" ] && [ "$IS_ELIXIR_CLOUD" = "true" ]; then - echo "Updating internal dependencies in $PACKAGE_NAME ($package_file)" - - # Update dependencies, devDependencies, and peerDependencies - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('./$package_file', 'utf8')); - let updated = false; - - // Function to update dependencies in a section - const updateDepsSection = (section, sectionName) => { - if (pkg[section]) { - Object.keys(pkg[section]).forEach(depName => { - if (depName.startsWith('@elixir-cloud/')) { - // Check if this dependency is not in ignore list - const ignoreList = '$IGNORE_PACKAGES'.split(','); - const shouldIgnoreDep = ignoreList.includes(depName); - - if (!shouldIgnoreDep && pkg[section][depName] !== '$RELEASE_VERSION') { - console.log(\` Updating \${depName} in \${sectionName}: \${pkg[section][depName]} -> $RELEASE_VERSION\`); - pkg[section][depName] = '$RELEASE_VERSION'; - updated = true; - } - } - }); - } - }; + # Published packages depend on the exact versions of the others + - name: Pin internal dependencies + run: node scripts/release.js pin "${VERSION}" - // Update all dependency sections - updateDepsSection('dependencies', 'dependencies'); - updateDepsSection('devDependencies', 'devDependencies'); - updateDepsSection('peerDependencies', 'peerDependencies'); - - // Write back if any updates were made - if (updated) { - fs.writeFileSync('./$package_file', JSON.stringify(pkg, null, 2) + '\n'); - } - " - fi - fi - done - fi - done - - - name: Build Packages + - name: Build packages run: npm run build - # Fallback until the first release with trusted publishing has worked - # (configured only right before publishing, not during install and - # build): - # npm tries OIDC first and uses this token only if that fails (e.g. no - # trusted publisher configured for a package). Then remove this step - # and revoke the `NPM_TOKEN` secret - - name: Configure npm token (fallback) - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # Staged versions aren't public until a maintainer approves them on + # npmjs.com. Versions already on npm are skipped, so that the job can + # be re-run; a version staged by an earlier run fails to stage again + - name: Stage packages on npm run: | - if [ -n "${NPM_TOKEN}" ]; then - echo "::notice::Using the NPM_TOKEN fallback for packages without a trusted publisher" - printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > ~/.npmrc + args=(--provenance --tag "${DIST_TAG}") + if [ "${DRY_RUN}" = "true" ]; then + echo "::notice::Dry run outside elixir-cloud-aai/cloud-components" + args+=(--dry-run) fi - - - name: Publish to npm - run: npm run release - - # Trusted publishing adds provenance; a version without it was published - # with the `NPM_TOKEN` fallback (check this before removing the token) - - name: Check provenance - run: | - for package_file in packages/*/package.json; do - name="$(node -p "const p = require('./${package_file}'); p.private || p.version !== '${RELEASE_VERSION}' ? '' : p.name")" - [ -n "${name}" ] || continue - if [ -z "$(npm view "${name}@${RELEASE_VERSION}" dist.attestations.provenance 2> /dev/null)" ]; then - echo "::warning::${name}@${RELEASE_VERSION} has no provenance: published with the NPM_TOKEN fallback, or not visible on npm yet" + staged=() + failed=() + while read -r dir; do + name="$(node -p "require('./${dir}/package.json').name")" + if [ -n "$(npm view "${name}@${VERSION}" version 2> /dev/null)" ]; then + echo "::notice::${name}@${VERSION} is on npm already" + continue + fi + echo "::group::${name}@${VERSION}" + if (cd "${dir}" && npm stage publish "${args[@]}"); then + staged+=("${name}@${VERSION}") + else + failed+=("${name}@${VERSION}") + echo "::error::Could not stage ${name}@${VERSION}" + fi + echo "::endgroup::" + done < <(node scripts/release.js packages) + { + echo "## npm" + echo + if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run: nothing was staged." + elif [ "${#staged[@]}" -gt 0 ]; then + echo "Staged with the dist-tag \`${DIST_TAG}\`; approve each version on npmjs.com (2FA):" fi - done + echo + for package in "${staged[@]}"; do + echo "- \`${package}\`" + done + for package in "${failed[@]}"; do + echo "- \`${package}\`: failed" + done + } >> "${GITHUB_STEP_SUMMARY}" + if [ "${#staged[@]}" -gt 0 ] && [ "${DRY_RUN}" != "true" ]; then + echo "::notice::Staged ${#staged[@]} packages: approve them on npmjs.com" + fi + [ "${#failed[@]}" -eq 0 ] # A GitHub release for the tag, with notes generated from the previous # version tag on (a prerelease for versions such as `2.0.0-alpha.49`). # Skipped if the release exists already, so that the job can be re-run github: name: Release on GitHub - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: publish permissions: contents: write # create the release From 134cae2f8c85327c0164b06daf6810c7ee256247 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 02:48:36 +0100 Subject: [PATCH 11/15] ci: harden the staging step of releases From the review: check out the tagged commit (github.sha) in all jobs, check the tag against `dev`, fail if the package list is empty or an npm lookup fails for another reason than "not found", and warn in the summary not to approve versions while others failed to stage. Test the command line of scripts/release.js and clean up test fixtures. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 48 ++++++++++++++++++++--------- scripts/release.js | 3 +- scripts/release.test.js | 57 +++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39717e2d..948cffb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,11 @@ # environment is approved, and creates a GitHub release for the tag. Staged # versions become public only when a maintainer approves them on npmjs.com. # The workflow pushes nothing. The trusted publishers on npmjs.com name this -# file (`release.yml`): keep the name +# file (`release.yml`): keep the name. # # Outside elixir-cloud-aai/cloud-components (e.g. in a fork, for a dry run), -# `npm stage publish` runs with `--dry-run` +# `npm stage publish` runs with `--dry-run`; the GitHub release is still +# created there. name: Release @@ -38,7 +39,9 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.ref }} + # The commit that the tag pointed to when the run started, also in + # later jobs, so that all jobs check, build and stage the same code + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -49,7 +52,7 @@ jobs: run: | version="${TAG#v}" if ! node scripts/release.js check "${version}"; then - echo "::error::Not all package versions are ${version}: merge the version bump pull request before tagging" + echo "::error::Version check failed for ${TAG} (see above): tag a commit with the versions of the version bump pull request" exit 1 fi { @@ -58,14 +61,14 @@ jobs: } >> "${GITHUB_OUTPUT}" - name: Check the branch - env: - BRANCH: ${{ github.event.repository.default_branch }} run: | - if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${BRANCH}"; then - echo "::error::The tag is not on ${BRANCH}" + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/dev; then + echo "::error::The tag is not on dev" exit 1 fi + # Lint and publish install the dependencies each, so that lint code never + # runs in the job with `id-token: write` lint: name: Lint release runs-on: ubuntu-24.04 @@ -75,7 +78,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.ref }} + ref: ${{ github.sha }} persist-credentials: false - name: Use Node.js 24.x @@ -112,7 +115,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.ref }} + ref: ${{ github.sha }} persist-credentials: false - name: Use Node.js 24.x @@ -156,12 +159,25 @@ jobs: echo "::notice::Dry run outside elixir-cloud-aai/cloud-components" args+=(--dry-run) fi + # Not `done < <(...)` below: that would hide a failure of the script + dirs="$(node scripts/release.js packages)" + if [ -z "${dirs}" ]; then + echo "::error::No packages to release" + exit 1 + fi staged=() failed=() while read -r dir; do name="$(node -p "require('./${dir}/package.json').name")" - if [ -n "$(npm view "${name}@${VERSION}" version 2> /dev/null)" ]; then - echo "::notice::${name}@${VERSION} is on npm already" + if output="$(npm view "${name}@${VERSION}" version 2>&1)"; then + if [ -n "${output}" ]; then + echo "::notice::${name}@${VERSION} is on npm already" + continue + fi + elif ! grep -q E404 <<< "${output}"; then + echo "${output}" + failed+=("${name}@${VERSION}") + echo "::error::Could not look up ${name}@${VERSION} on npm" continue fi echo "::group::${name}@${VERSION}" @@ -172,12 +188,12 @@ jobs: echo "::error::Could not stage ${name}@${VERSION}" fi echo "::endgroup::" - done < <(node scripts/release.js packages) + done <<< "${dirs}" { echo "## npm" echo if [ "${DRY_RUN}" = "true" ]; then - echo "Dry run: nothing was staged." + echo "Dry run: nothing was staged; these packages would be:" elif [ "${#staged[@]}" -gt 0 ]; then echo "Staged with the dist-tag \`${DIST_TAG}\`; approve each version on npmjs.com (2FA):" fi @@ -188,6 +204,10 @@ jobs: for package in "${failed[@]}"; do echo "- \`${package}\`: failed" done + if [ "${#failed[@]}" -gt 0 ]; then + echo + echo "**Don't approve any version until all packages are staged:** the packages depend on each other's exact versions." + fi } >> "${GITHUB_STEP_SUMMARY}" if [ "${#staged[@]}" -gt 0 ] && [ "${DRY_RUN}" != "true" ]; then echo "::notice::Staged ${#staged[@]} packages: approve them on npmjs.com" diff --git a/scripts/release.js b/scripts/release.js index 5ade0f12..2532c8ba 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -28,7 +28,6 @@ const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, ".."); -const scope = "@elixir-cloud/"; // Semantic Versioning 2.0.0, without build metadata const semverPattern = @@ -102,7 +101,7 @@ const pinInternalDependencies = (version, base = root) => { let changed = false; ["dependencies", "peerDependencies"].forEach((section) => { Object.keys(manifest[section] || {}) - .filter((name) => name.startsWith(scope) && names.has(name)) + .filter((name) => names.has(name)) .forEach((name) => { if (manifest[section][name] !== version) { changes.push( diff --git a/scripts/release.test.js b/scripts/release.test.js index 32faac02..f31ab688 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -4,6 +4,7 @@ const assert = require("assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); +const { spawnSync } = require("child_process"); const { test } = require("node:test"); const { @@ -23,8 +24,10 @@ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); // A repository with a root, two public packages, a private one and a // directory without `package.json` -const fixture = (version = "2.0.0-alpha.49") => { +// (removed when the test `t` ends) +const fixture = (t, version = "2.0.0-alpha.49") => { const base = fs.mkdtempSync(path.join(os.tmpdir(), "release-test-")); + t.after(() => fs.rmSync(base, { recursive: true, force: true })); writeJson(path.join(base, "package.json"), { name: "root", version }); writeJson(path.join(base, "packages", "b-design", "package.json"), { name: "@elixir-cloud/design", @@ -78,20 +81,20 @@ test("distTag uses latest for releases and the prerelease identifier", () => { assert.strictEqual(distTag("2.1.0-latest.1"), "next"); }); -test("releasedPackages lists public packages, sorted by directory", () => { - const base = fixture(); +test("releasedPackages lists public packages, sorted by directory", (t) => { + const base = fixture(t); assert.deepStrictEqual( releasedPackages(base).map(({ dir }) => dir), [path.join("packages", "a-wes"), path.join("packages", "b-design")] ); }); -test("versionMismatches is empty when all versions match", () => { - assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", fixture()), []); +test("versionMismatches is empty when all versions match", (t) => { + assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", fixture(t)), []); }); -test("versionMismatches names the root and packages with other versions", () => { - const base = fixture(); +test("versionMismatches names the root and packages with other versions", (t) => { + const base = fixture(t); const design = path.join(base, "packages", "b-design", "package.json"); writeJson(design, { ...readJson(design), version: "2.0.0-alpha.48" }); assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", base), [ @@ -100,15 +103,20 @@ test("versionMismatches names the root and packages with other versions", () => "b-design" )}) has version 2.0.0-alpha.48, not 2.0.0-alpha.49`, ]); - assert.strictEqual(versionMismatches("2.0.0-alpha.50", base).length, 3); + assert.deepStrictEqual( + versionMismatches("2.0.0-alpha.50", base).map((message) => + message.slice(0, message.indexOf(" ")) + ), + ["root", "@elixir-cloud/wes", "@elixir-cloud/design"] + ); assert.throws( () => versionMismatches("v2.0.0-alpha.49", base), /Not a version/ ); }); -test("pinInternalDependencies pins dependencies on released packages only", () => { - const base = fixture(); +test("pinInternalDependencies pins dependencies on released packages only", (t) => { + const base = fixture(t); const changes = pinInternalDependencies("2.0.0-alpha.49", base); assert.strictEqual(changes.length, 2); const wes = readJson(path.join(base, "packages", "a-wes", "package.json")); @@ -128,3 +136,32 @@ test("pinInternalDependencies pins dependencies on released packages only", () = }); assert.deepStrictEqual(pinInternalDependencies("2.0.0-alpha.49", base), []); }); + +// The command line, run on this repository +const run = (...args) => + spawnSync(process.execPath, [path.join(__dirname, "release.js"), ...args], { + encoding: "utf8", + }); + +test("the command line prints the dist-tag and released packages", () => { + const distTagRun = run("dist-tag", "2.0.0-alpha.49"); + assert.strictEqual(distTagRun.status, 0); + assert.strictEqual(distTagRun.stdout, "alpha\n"); + const packagesRun = run("packages"); + assert.strictEqual(packagesRun.status, 0); + const dirs = packagesRun.stdout.trim().split("\n"); + assert.ok(dirs.includes(path.join("packages", "ecc-utils-design"))); + assert.ok(!dirs.includes(path.join("packages", "ecc-client-ga4gh-tes"))); +}); + +test("the command line fails on bad versions and commands", () => { + const badVersion = run("check", "v2.0.0-alpha.49"); + assert.strictEqual(badVersion.status, 1); + assert.match(badVersion.stderr, /Not a version/); + const missingVersion = run("pin"); + assert.strictEqual(missingVersion.status, 1); + assert.match(missingVersion.stderr, /Not a version/); + const badCommand = run("publish"); + assert.strictEqual(badCommand.status, 2); + assert.match(badCommand.stderr, /Usage/); +}); From c7c2305cfff233308b98ffb371da59e83040b5a5 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 03:14:04 +0100 Subject: [PATCH 12/15] ci: count versions staged earlier as staged From the second review: `npm view` doesn't see staged versions, so a re-run tried to stage them again and failed on the conflict (E409) forever. Count those as staged (the same commit, as tags are immutable), keep stdin away from npm in the loop, and say in the header that tags must be on `dev`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 948cffb9..6ea32d62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ # Releases from version tags (see "Releases" in CONTRIBUTING.md): after the -# version bump pull request, a maintainer pushes `v` on `dev`. The +# version bump pull request, a maintainer pushes `v` on `dev` (tags +# on other branches fail). The # workflow checks the tag, lints, builds the packages from the tag, stages # them on npm with trusted publishing (OIDC, with provenance) once the `npm` # environment is approved, and creates a GitHub release for the tag. Staged @@ -151,7 +152,9 @@ jobs: # Staged versions aren't public until a maintainer approves them on # npmjs.com. Versions already on npm are skipped, so that the job can - # be re-run; a version staged by an earlier run fails to stage again + # be re-run; `npm view` doesn't see staged versions, so a version + # staged by an earlier run fails to stage again with a conflict (E409) + # and counts as staged (the same commit, as tags are immutable) - name: Stage packages on npm run: | args=(--provenance --tag "${DIST_TAG}") @@ -165,11 +168,12 @@ jobs: echo "::error::No packages to release" exit 1 fi + log="$(mktemp)" staged=() failed=() while read -r dir; do name="$(node -p "require('./${dir}/package.json').name")" - if output="$(npm view "${name}@${VERSION}" version 2>&1)"; then + if output="$(npm view "${name}@${VERSION}" version 2>&1 < /dev/null)"; then if [ -n "${output}" ]; then echo "::notice::${name}@${VERSION} is on npm already" continue @@ -181,8 +185,12 @@ jobs: continue fi echo "::group::${name}@${VERSION}" - if (cd "${dir}" && npm stage publish "${args[@]}"); then + if (cd "${dir}" && npm stage publish "${args[@]}" < /dev/null 2>&1) | tee "${log}" \ + && [ "${PIPESTATUS[0]}" -eq 0 ]; then staged+=("${name}@${VERSION}") + elif grep -q "code E409" "${log}"; then + echo "::warning::${name}@${VERSION} was staged earlier" + staged+=("${name}@${VERSION} (staged earlier)") else failed+=("${name}@${VERSION}") echo "::error::Could not stage ${name}@${VERSION}" From 300caa957ea160c2f8fddc2cd36b86ec9780a13d Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 03:16:24 +0100 Subject: [PATCH 13/15] ci: rewrap the release workflow header Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ea32d62..34db08ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ # Releases from version tags (see "Releases" in CONTRIBUTING.md): after the # version bump pull request, a maintainer pushes `v` on `dev` (tags -# on other branches fail). The -# workflow checks the tag, lints, builds the packages from the tag, stages -# them on npm with trusted publishing (OIDC, with provenance) once the `npm` -# environment is approved, and creates a GitHub release for the tag. Staged -# versions become public only when a maintainer approves them on npmjs.com. +# on other branches fail). The workflow checks the tag, lints, builds the +# packages from the tag, stages them on npm with trusted publishing (OIDC, +# with provenance) once the `npm` environment is approved, and creates a +# GitHub release for the tag. Staged versions become public only when a +# maintainer approves them on npmjs.com. # The workflow pushes nothing. The trusted publishers on npmjs.com name this # file (`release.yml`): keep the name. # From bc026ea0edd95ef79cd8b3f2dd9e78f97e25a0da Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 03:55:51 +0100 Subject: [PATCH 14/15] ci: stage 2.0.0 alphas with the latest dist-tag Decided by the maintainer: as before, the 2.0.0 alphas go to `latest`. Prereleases of a major version without a release yet (no tag v.x.y without suffix) get `latest`; once the major is released, prereleases get their identifier (`rc` for 2.1.0-rc.1). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 1 + scripts/release.js | 38 ++++++++++++++++++++++++++--------- scripts/release.test.js | 21 +++++++++++++------ 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34db08ea..587e1a1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,7 @@ jobs: # The commit that the tag pointed to when the run started, also in # later jobs, so that all jobs check, build and stage the same code ref: ${{ github.sha }} + # All history and tags: for the branch check and the dist-tag fetch-depth: 0 persist-credentials: false diff --git a/scripts/release.js b/scripts/release.js index 2532c8ba..4e988ac8 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -13,9 +13,9 @@ // released packages, one per line // node scripts/release.js dist-tag // print the npm dist-tag for -// : `latest`, or the -// prerelease identifier (`alpha` -// for `2.0.0-alpha.49`) +// (see `distTag`; reads +// the version tags of the +// repository with `git tag`) // node scripts/release.js pin set the `@elixir-cloud/*` // dependencies and peer // dependencies of the released @@ -43,7 +43,10 @@ const parseVersion = (version) => { if (!match) { throw new Error(`Not a version (e.g. 2.0.0-alpha.49): '${version}'`); } - return { prerelease: match[4] ? match[4].split(".") : [] }; + return { + major: Number(match[1]), + prerelease: match[4] ? match[4].split(".") : [], + }; }; // Public packages in `packages/*`, sorted by directory @@ -78,11 +81,20 @@ const versionMismatches = (version, base = root) => { ); }; -// `latest` for releases, the first prerelease identifier otherwise (or -// `next`, if it is a number) -const distTag = (version) => { - const { prerelease } = parseVersion(version); - if (prerelease.length === 0) { +// The dist-tag for `version`, given the repository's tags (e.g. `v1.0.1`): +// `latest` for releases, and also for prereleases of a major version +// without a release yet (the 2.0.0 alphas, until 2.0.0 is out). Other +// prereleases get their first identifier (`rc` for `2.1.0-rc.1`), or +// `next` if it is a number +const distTag = (version, tags = []) => { + const { major, prerelease } = parseVersion(version); + const majorReleased = tags.some((tag) => { + const parsed = semverPattern.test(tag.replace(/^v/, "")) + ? parseVersion(tag.replace(/^v/, "")) + : null; + return parsed && parsed.major === major && parsed.prerelease.length === 0; + }); + if (prerelease.length === 0 || !majorReleased) { return "latest"; } return /^[a-zA-Z][0-9a-zA-Z-]*$/.test(prerelease[0]) && @@ -119,6 +131,12 @@ const pinInternalDependencies = (version, base = root) => { return changes; }; +// The repository's tags `v*` +const versionTags = () => + execFileSync("git", ["tag", "--list", "v*"], { cwd: root, encoding: "utf8" }) + .split("\n") + .filter(Boolean); + // Sets the version with `npm version`, which also updates the lock file const setVersion = (version) => { parseVersion(version); @@ -154,7 +172,7 @@ const main = (args) => { releasedPackages().forEach(({ dir }) => console.log(dir)); return 0; case "dist-tag": - console.log(distTag(version)); + console.log(distTag(version, versionTags())); return 0; case "pin": pinInternalDependencies(version).forEach((change) => console.log(change)); diff --git a/scripts/release.test.js b/scripts/release.test.js index f31ab688..5c202a4e 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -74,11 +74,19 @@ test("parseVersion accepts versions and rejects other strings", () => { }); test("distTag uses latest for releases and the prerelease identifier", () => { - assert.strictEqual(distTag("2.0.0"), "latest"); - assert.strictEqual(distTag("2.0.0-alpha.49"), "alpha"); - assert.strictEqual(distTag("2.1.0-rc.1"), "rc"); - assert.strictEqual(distTag("2.1.0-1"), "next"); - assert.strictEqual(distTag("2.1.0-latest.1"), "next"); + const tags = ["v1.0.0", "v1.0.1", "v2.0.0-alpha.48", "not-a-version", "v3"]; + assert.strictEqual(distTag("2.0.0", tags), "latest"); + assert.strictEqual(distTag("1.1.0-rc.1", tags), "rc"); + assert.strictEqual(distTag("1.1.0-1", tags), "next"); + assert.strictEqual(distTag("1.1.0-latest.1", tags), "next"); +}); + +test("distTag uses latest for prereleases of an unreleased major", () => { + const tags = ["v1.0.1", "v2.0.0-alpha.48"]; + assert.strictEqual(distTag("2.0.0-alpha.49", tags), "latest"); + assert.strictEqual(distTag("2.0.0-alpha.49"), "latest"); + assert.strictEqual(distTag("2.1.0-rc.1", [...tags, "v2.0.0"]), "rc"); + assert.strictEqual(distTag("3.0.0-alpha.1", [...tags, "v2.0.0"]), "latest"); }); test("releasedPackages lists public packages, sorted by directory", (t) => { @@ -144,9 +152,10 @@ const run = (...args) => }); test("the command line prints the dist-tag and released packages", () => { + // `latest` with or without the repository's tags (CI clones may have none) const distTagRun = run("dist-tag", "2.0.0-alpha.49"); assert.strictEqual(distTagRun.status, 0); - assert.strictEqual(distTagRun.stdout, "alpha\n"); + assert.strictEqual(distTagRun.stdout, "latest\n"); const packagesRun = run("packages"); assert.strictEqual(packagesRun.status, 0); const dirs = packagesRun.stdout.trim().split("\n"); From 0202edba947db2204754de3825595151ff9ab358 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Tue, 27 Oct 2026 04:25:30 +0100 Subject: [PATCH 15/15] ci: fail the dist-tag without release tags From the review: without any release tag (e.g. a clone without tags), every prerelease would get `latest`. Fail instead, also in the workflow (the dist-tag is no longer computed inside `echo`), wrap git errors, and tidy the tests and comments. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 5 +++- scripts/release.js | 49 +++++++++++++++++++++++------------ scripts/release.test.js | 14 +++++----- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 587e1a1b..5697e1f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,9 +57,12 @@ jobs: echo "::error::Version check failed for ${TAG} (see above): tag a commit with the versions of the version bump pull request" exit 1 fi + # From the git tags (see `fetch-depth`); fails without release tags + dist_tag="$(node scripts/release.js dist-tag "${version}")" + echo "::notice::Dist-tag for ${version}: ${dist_tag}" { echo "version=${version}" - echo "dist-tag=$(node scripts/release.js dist-tag "${version}")" + echo "dist-tag=${dist_tag}" } >> "${GITHUB_OUTPUT}" - name: Check the branch diff --git a/scripts/release.js b/scripts/release.js index 4e988ac8..5401bb0b 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -13,9 +13,11 @@ // released packages, one per line // node scripts/release.js dist-tag // print the npm dist-tag for -// (see `distTag`; reads -// the version tags of the -// repository with `git tag`) +// : `latest` for +// releases and for prereleases of +// a major without a release tag +// yet, else the prerelease +// identifier (needs the git tags) // node scripts/release.js pin set the `@elixir-cloud/*` // dependencies and peer // dependencies of the released @@ -85,16 +87,22 @@ const versionMismatches = (version, base = root) => { // `latest` for releases, and also for prereleases of a major version // without a release yet (the 2.0.0 alphas, until 2.0.0 is out). Other // prereleases get their first identifier (`rc` for `2.1.0-rc.1`), or -// `next` if it is a number -const distTag = (version, tags = []) => { +// `next` if it is a number. Fails without any release tag (e.g. in a clone +// without tags), which would otherwise send every prerelease to `latest` +const distTag = (version, tags) => { const { major, prerelease } = parseVersion(version); - const majorReleased = tags.some((tag) => { - const parsed = semverPattern.test(tag.replace(/^v/, "")) - ? parseVersion(tag.replace(/^v/, "")) - : null; - return parsed && parsed.major === major && parsed.prerelease.length === 0; - }); - if (prerelease.length === 0 || !majorReleased) { + const releases = tags + .map((tag) => tag.replace(/^v/, "")) + .filter((tag) => semverPattern.test(tag)) + .map(parseVersion) + .filter((parsed) => parsed.prerelease.length === 0); + if (releases.length === 0) { + throw new Error("No release tags (e.g. v1.0.1) found: fetch the tags"); + } + if ( + prerelease.length === 0 || + !releases.some((parsed) => parsed.major === major) + ) { return "latest"; } return /^[a-zA-Z][0-9a-zA-Z-]*$/.test(prerelease[0]) && @@ -132,10 +140,19 @@ const pinInternalDependencies = (version, base = root) => { }; // The repository's tags `v*` -const versionTags = () => - execFileSync("git", ["tag", "--list", "v*"], { cwd: root, encoding: "utf8" }) - .split("\n") - .filter(Boolean); +const versionTags = () => { + try { + return execFileSync("git", ["tag", "--list", "v*"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + .split("\n") + .filter(Boolean); + } catch (error) { + throw new Error(`Can't read the git tags: ${error.message}`); + } +}; // Sets the version with `npm version`, which also updates the lock file const setVersion = (version) => { diff --git a/scripts/release.test.js b/scripts/release.test.js index 5c202a4e..09a3e203 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -73,7 +73,7 @@ test("parseVersion accepts versions and rejects other strings", () => { ); }); -test("distTag uses latest for releases and the prerelease identifier", () => { +test("distTag uses the prerelease identifier once the major is released", () => { const tags = ["v1.0.0", "v1.0.1", "v2.0.0-alpha.48", "not-a-version", "v3"]; assert.strictEqual(distTag("2.0.0", tags), "latest"); assert.strictEqual(distTag("1.1.0-rc.1", tags), "rc"); @@ -84,7 +84,11 @@ test("distTag uses latest for releases and the prerelease identifier", () => { test("distTag uses latest for prereleases of an unreleased major", () => { const tags = ["v1.0.1", "v2.0.0-alpha.48"]; assert.strictEqual(distTag("2.0.0-alpha.49", tags), "latest"); - assert.strictEqual(distTag("2.0.0-alpha.49"), "latest"); + assert.throws(() => distTag("2.0.0-alpha.49", []), /No release tags/); + assert.throws( + () => distTag("2.0.0-alpha.49", ["v2.0.0-alpha.48", "v2.0.0+build"]), + /No release tags/ + ); assert.strictEqual(distTag("2.1.0-rc.1", [...tags, "v2.0.0"]), "rc"); assert.strictEqual(distTag("3.0.0-alpha.1", [...tags, "v2.0.0"]), "latest"); }); @@ -151,11 +155,7 @@ const run = (...args) => encoding: "utf8", }); -test("the command line prints the dist-tag and released packages", () => { - // `latest` with or without the repository's tags (CI clones may have none) - const distTagRun = run("dist-tag", "2.0.0-alpha.49"); - assert.strictEqual(distTagRun.status, 0); - assert.strictEqual(distTagRun.stdout, "latest\n"); +test("the command line prints the released packages", () => { const packagesRun = run("packages"); assert.strictEqual(packagesRun.status, 0); const dirs = packagesRun.stdout.trim().split("\n");