diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 0f745e89..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,291 +0,0 @@ -name: Publish packages - -on: - push: - tags: - - 'v*' - -jobs: - - linters: - name: Linting checks - runs-on: ubuntu-24.04 - - 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 - - - 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: Build - # run: npm run build - - # - name: Run tests - # run: npm run test - - bump-monorepo: - name: Bump monorepo - runs-on: ubuntu-latest - needs: - - linters - 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - - - 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 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24.x - # No dependency cache in releases, see above - 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 - - publish: - name: Publish on npm - runs-on: ubuntu-latest - needs: bump-monorepo - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - - - 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 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24.x - # 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 - - - 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; - } - } - }); - } - }; - - // 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 - run: npm run build - - - name: Publish to npm - run: npm run release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5697e1f3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,262 @@ +# 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. +# 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`; the GitHub release is still +# created there. + +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +# One release at a time; never cancel a running release +concurrency: + group: release + cancel-in-progress: false + +jobs: + + 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: + # 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 + + - 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::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=${dist_tag}" + } >> "${GITHUB_OUTPUT}" + + - name: Check the branch + run: | + 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 + needs: readiness + + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - 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 + + - name: Install dependencies + run: npm ci + + - name: Run linters + run: npm run lint + + publish: + name: Release packages + 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: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - 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 + + # `npm stage publish` needs npm 11.15.0 or later + - name: Check npm version + run: | + version="$(npm --version)" + if ! node -e ' + 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} has no 'npm stage publish' (needs 11.15.0 or later)" + exit 1 + fi + + - name: Install dependencies + run: npm ci + + # Published packages depend on the exact versions of the others + - name: Pin internal dependencies + run: node scripts/release.js pin "${VERSION}" + + - name: Build packages + run: npm run build + + # 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; `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}") + if [ "${DRY_RUN}" = "true" ]; then + 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 + 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 < /dev/null)"; 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}" + 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}" + fi + echo "::endgroup::" + done <<< "${dirs}" + { + echo "## npm" + echo + if [ "${DRY_RUN}" = "true" ]; then + 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 + echo + for package in "${staged[@]}"; do + echo "- \`${package}\`" + done + 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" + 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-24.04 + 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, 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]' | 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}") + fi + if [[ "${TAG}" == *-* ]]; then + args+=(--prerelease) + fi + gh release create "${TAG}" "${args[@]}" 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..5401bb0b --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,220 @@ +#!/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` 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 +// 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, ".."); + +// 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 { + major: Number(match[1]), + 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}` + ); +}; + +// 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. 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 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]) && + 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) => 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; +}; + +// The repository's tags `v*` +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) => { + 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, versionTags())); + 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..09a3e203 --- /dev/null +++ b/scripts/release.test.js @@ -0,0 +1,176 @@ +// 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 { spawnSync } = require("child_process"); +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` +// (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", + 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 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"); + 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.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"); +}); + +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", (t) => { + assert.deepStrictEqual(versionMismatches("2.0.0-alpha.49", fixture(t)), []); +}); + +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), [ + `@elixir-cloud/design (${path.join( + "packages", + "b-design" + )}) has version 2.0.0-alpha.48, not 2.0.0-alpha.49`, + ]); + 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", (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")); + 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), []); +}); + +// 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 released packages", () => { + 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/); +});