diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml new file mode 100644 index 0000000..22e9fe6 --- /dev/null +++ b/.github/workflows/node-release.yml @@ -0,0 +1,150 @@ +name: Publish Node package to npm + +# The npm trusted publisher must reference this exact filename and the `npm` +# environment (https://docs.npmjs.com/trusted-publishers). + +on: + release: + types: [published] + +permissions: + contents: read + +# cancel-in-progress: false — cancelling mid-publish can leave a release half done. +concurrency: + group: node-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Source of truth for the test matrix: package.json's supportedNodeVersions. + # node-v* tags only — test/build/publish inherit this via `needs`. + versions: + name: node versions + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Generate supported versions from package.json + id: set + working-directory: node + run: echo "data=$(npm run --silent list-supported-versions)" >> "$GITHUB_OUTPUT" + + # Full support matrix gates the release. Ubuntu only: the package is pure JavaScript + # and the CI workflow already covers Windows and macOS. No dependency caching in + # release jobs, per npm's trusted-publishing guidance. + test: + name: test (Node ${{ matrix.node-version }}) + needs: versions + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false # report every version, not just the first failure + matrix: + node-version: ${{ fromJson(needs.versions.outputs.data).all }} + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + package-manager-cache: false + + - name: Install and run the test suite + timeout-minutes: 10 + run: | + npm ci + npm test + + build: + name: build and verify + needs: [versions, test] + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ fromJson(needs.versions.outputs.data).max }} + package-manager-cache: false + + # Tag must match package.json's version, `private` must be gone, CHANGELOG must + # have an entry, and repository.url must be set (provenance verifies it). + - name: Verify the tag matches the packaged version and the package is publishable + working-directory: node + run: npm run --silent verify -- "$GITHUB_REF_NAME" + + - name: Build and pack the tarball + working-directory: node + timeout-minutes: 5 + run: | + npm ci + npm run build + mkdir pack + npm pack --pack-destination pack + + - name: Save the tarball for the publish job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-dist + path: node/pack/ + if-no-files-found: error # an empty artifact silently breaks the publish job + + publish: + name: publish + needs: [versions, build] + # A fork that copies this workflow and its tags can never publish. + if: startsWith(github.ref_name, 'node-v') && github.repository_owner == 'aws' + runs-on: ubuntu-latest + timeout-minutes: 20 + # Must match the environment name on the npm trusted publisher; carries the + # required-reviewer gate so a run alone cannot publish. + environment: npm + permissions: + # Job permissions REPLACE the top-level block, so restate contents: read. + contents: read + id-token: write # OIDC token for npm trusted publishing + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # No NODE_AUTH_TOKEN: auth comes from the OIDC token, which npm >= 11.5.1 uses + # automatically. + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ fromJson(needs.versions.outputs.data).max }} + registry-url: https://registry.npmjs.org + package-manager-cache: false + + # The newest supported Node bundles npm >= 11.5.1; assert it rather than install + # npm from the registry at publish time. + - name: Verify again on the publishing runner, including the npm version + working-directory: node + run: npm run --silent verify -- "$GITHUB_REF_NAME" --require-npm 11.5.1 + + - name: Retrieve the tarball from the build job + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-dist + path: node/pack/ + + - name: Publish to npm with provenance + run: npm publish node/pack/*.tgz --provenance --access public + + # Retries for ~13 minutes (20 x 40s): the registry lags behind a publish. + - name: Smoke-test the published package + run: python3 scripts/node_dev.py smoke "${GITHUB_REF_NAME#node-v}" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7f94871..cc52543 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,12 +7,12 @@ on: branches: [main] paths: - "python/**" - - "scripts/**" + - "scripts/python_dev.py" - ".github/workflows/python.yml" pull_request: paths: - "python/**" - - "scripts/**" + - "scripts/python_dev.py" - ".github/workflows/python.yml" jobs: diff --git a/node/.gitignore b/node/.gitignore index 4abc033..cc1b485 100644 --- a/node/.gitignore +++ b/node/.gitignore @@ -5,6 +5,7 @@ node_modules/ # npm *.tgz +pack/ npm-debug.log* # Test / coverage output diff --git a/node/package.json b/node/package.json index 0fa62ba..0b78ec6 100644 --- a/node/package.json +++ b/node/package.json @@ -2,6 +2,13 @@ "name": "@aws/developer-toolkit-elasticache", "version": "0.1.0-dev.0", "description": "Developer toolkit for Amazon ElastiCache", + "repository": { + "type": "git", + "url": "git+https://github.com/aws/developer-toolkit-elasticache.git", + "directory": "node" + }, + "homepage": "https://github.com/aws/developer-toolkit-elasticache/tree/main/node#readme", + "bugs": "https://github.com/aws/developer-toolkit-elasticache/issues", "type": "module", "private": true, "license": "Apache-2.0", @@ -19,6 +26,9 @@ "NOTICE", "README.md" ], + "publishConfig": { + "access": "public" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -35,6 +45,7 @@ "format:check": "prettier --check .", "lint": "eslint .", "list-supported-versions": "node scripts/list-supported-versions.mjs", + "verify": "node scripts/verify.mjs", "package:check": "npm run clean && npm run build && npm pack --dry-run", "test": "npm run build && npm run build:test && node --test dist-test/test/*.test.js", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs new file mode 100644 index 0000000..f63d3b2 --- /dev/null +++ b/node/scripts/verify.mjs @@ -0,0 +1,118 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Release gate, run by node-release.yml before anything is built or published: +// +// node scripts/verify.mjs [TAG] [--require-npm ] +// +// Checks that the release tag matches package.json's version, that the package is no +// longer marked private (the publish guard), that the version is not a prerelease, that +// CHANGELOG.md has an entry for the version, and that package.json declares the +// repository. +// With --require-npm, also checks the local npm is at least that version, which the +// publish job needs for trusted publishing. Prints a GitHub Actions error annotation +// and exits non-zero on the first failure. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { argv, env, exit, platform, stdout } from "node:process"; +import { URL } from "node:url"; + +const TAG_PREFIX = "node-v"; +const EXPECTED_REPOSITORY = "https://github.com/aws/developer-toolkit-elasticache"; + +function fail(message) { + stdout.write(`::error::${message}\n`); + exit(1); +} + +function versionParts(value, what) { + const parts = value.split(".").map(Number); + if (parts.some((part) => !Number.isInteger(part) || part < 0)) { + fail(`${what} is not a dotted numeric version: ${value}`); + } + return parts; +} + +/** True when dotted version `a` is older than `b`. */ +function olderThan(a, b) { + const [left, right] = [ + versionParts(a, "the installed npm version"), + versionParts(b, "--require-npm"), + ]; + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const [l, r] = [left[i] ?? 0, right[i] ?? 0]; + if (l !== r) { + return l < r; + } + } + return false; +} + +const args = argv.slice(2); +const flagIndex = args.indexOf("--require-npm"); +const requiredNpm = flagIndex === -1 ? undefined : args[flagIndex + 1]; +if (flagIndex !== -1 && (requiredNpm === undefined || requiredNpm.startsWith("-"))) { + fail("--require-npm needs a version, for example --require-npm 11.5.1."); +} +const positional = + flagIndex === -1 ? args : args.filter((_, i) => i !== flagIndex && i !== flagIndex + 1); +const tag = positional[0] ?? env.GITHUB_REF_NAME ?? ""; + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const version = packageJson.version; + +if (tag !== `${TAG_PREFIX}${version}`) { + fail( + `Release tag does not match the version in package.json (${version}). ` + + `Expected ${TAG_PREFIX}${version}. Bump the version and re-tag.`, + ); +} + +if (packageJson.private === true) { + fail( + 'package.json still has "private": true. Remove it in the release commit once ' + + "the open-source review has cleared.", + ); +} + +if (version.includes("-")) { + fail( + `Version ${version} is a prerelease. Only release versions (X.Y.Z) are published.`, + ); +} + +const repositoryUrl = + typeof packageJson.repository === "string" + ? packageJson.repository + : packageJson.repository?.url; +if (!repositoryUrl || !repositoryUrl.includes(EXPECTED_REPOSITORY)) { + fail( + `package.json repository.url must point at ${EXPECTED_REPOSITORY}; ` + + "npm provenance verifies it against the publishing repository.", + ); +} + +const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); +if (!changelog.includes(`## [${version}]`)) { + fail(`CHANGELOG.md has no "## [${version}]" entry. Add one in the release commit.`); +} + +if (requiredNpm !== undefined) { + // Windows: npm is a .cmd shim, which Node only spawns through a shell. + const npm = platform === "win32" ? "npm.cmd" : "npm"; + const installed = execFileSync(npm, ["--version"], { + encoding: "utf8", + shell: platform === "win32", + }).trim(); + if (olderThan(installed, requiredNpm)) { + fail( + `npm ${installed} is older than ${requiredNpm}, the minimum for trusted ` + + "publishing. Use a Node.js release that bundles a newer npm.", + ); + } +} + +stdout.write(`Publishing version ${version}.\n`); diff --git a/node/test/verify.test.ts b/node/test/verify.test.ts new file mode 100644 index 0000000..261bf2f --- /dev/null +++ b/node/test/verify.test.ts @@ -0,0 +1,156 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const SCRIPT_PATH = join(PACKAGE_ROOT, "scripts", "verify.mjs"); + +const RELEASABLE = { + name: "@aws/developer-toolkit-elasticache", + version: "1.2.3", + repository: { + type: "git", + url: "git+https://github.com/aws/developer-toolkit-elasticache.git", + }, +}; +const CHANGELOG_WITH_ENTRY = "# Changelog\n\n## [1.2.3] - 2026-01-01\n\n- Something.\n"; + +function runVerify( + args: string[], + packageJson: Record, + changelog = CHANGELOG_WITH_ENTRY, + env: Record = { GITHUB_REF_NAME: "" }, +) { + const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-verify-")); + const scriptsDirectory = join(temporaryRoot, "scripts"); + const temporaryScript = join(scriptsDirectory, "verify.mjs"); + + try { + mkdirSync(scriptsDirectory); + copyFileSync(SCRIPT_PATH, temporaryScript); + writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(packageJson)); + writeFileSync(join(temporaryRoot, "CHANGELOG.md"), changelog); + return spawnSync(process.execPath, [temporaryScript, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +test("accepts a matching tag on a publishable package", () => { + const result = runVerify(["node-v1.2.3"], RELEASABLE); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /Publishing version 1\.2\.3\./); +}); + +test("reads the tag from GITHUB_REF_NAME when no argument is given", () => { + const result = runVerify([], RELEASABLE, CHANGELOG_WITH_ENTRY, { + GITHUB_REF_NAME: "node-v1.2.3", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +const rejectedCases: { + name: string; + args: string[]; + packageJson: Record; + changelog?: string; + expectedError: RegExp; +}[] = [ + { + name: "rejects a tag that does not match the version", + args: ["node-v1.2.4"], + packageJson: RELEASABLE, + expectedError: /does not match the version in package\.json \(1\.2\.3\)/, + }, + { + name: "rejects a tag without the node-v prefix", + args: ["v1.2.3"], + packageJson: RELEASABLE, + expectedError: /Expected node-v1\.2\.3/, + }, + { + name: "rejects a package still marked private", + args: ["node-v1.2.3"], + packageJson: { ...RELEASABLE, private: true }, + expectedError: /"private": true/, + }, + { + name: "rejects a missing repository url", + args: ["node-v1.2.3"], + packageJson: { name: RELEASABLE.name, version: RELEASABLE.version }, + expectedError: /repository\.url must point at/, + }, + { + name: "rejects a changelog without an entry for the version", + args: ["node-v1.2.3"], + packageJson: RELEASABLE, + changelog: "# Changelog\n\n## [Unreleased]\n\n- Something.\n", + expectedError: /CHANGELOG\.md has no "## \[1\.2\.3\]" entry/, + }, + { + name: "rejects a release candidate", + args: ["node-v1.2.3-rc.1"], + packageJson: { ...RELEASABLE, version: "1.2.3-rc.1" }, + changelog: "# Changelog\n\n## [1.2.3-rc.1] - 2026-01-01\n\n- Something.\n", + expectedError: /1\.2\.3-rc\.1 is a prerelease/, + }, + { + name: "rejects a dev version", + args: ["node-v0.1.0-dev.0"], + packageJson: { ...RELEASABLE, version: "0.1.0-dev.0" }, + changelog: "# Changelog\n\n## [0.1.0-dev.0] - 2026-01-01\n\n- Something.\n", + expectedError: /0\.1\.0-dev\.0 is a prerelease/, + }, + { + name: "rejects --require-npm without a value", + args: ["node-v1.2.3", "--require-npm"], + packageJson: RELEASABLE, + expectedError: /--require-npm needs a version/, + }, + { + name: "rejects --require-npm followed by another flag", + args: ["node-v1.2.3", "--require-npm", "--whatever"], + packageJson: RELEASABLE, + expectedError: /--require-npm needs a version/, + }, + { + name: "rejects a non-numeric --require-npm value", + args: ["node-v1.2.3", "--require-npm", "eleven"], + packageJson: RELEASABLE, + expectedError: /not a dotted numeric version/, + }, + { + name: "rejects an npm older than the required version", + args: ["node-v1.2.3", "--require-npm", "999.0.0"], + packageJson: RELEASABLE, + expectedError: /older than 999\.0\.0/, + }, +]; + +for (const testCase of rejectedCases) { + test(testCase.name, () => { + const result = runVerify(testCase.args, testCase.packageJson, testCase.changelog); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /^::error::/m); + assert.match(result.stdout, testCase.expectedError); + }); +} + +test("accepts an npm at or above the required version", () => { + const result = runVerify(["node-v1.2.3", "--require-npm", "1.0.0"], RELEASABLE); + + assert.equal(result.status, 0, result.stdout + result.stderr); +}); diff --git a/scripts/node_dev.py b/scripts/node_dev.py new file mode 100644 index 0000000..e4e1a43 --- /dev/null +++ b/scripts/node_dev.py @@ -0,0 +1,92 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# ruff: noqa: INP001, T201, S603, S607 # a CLI script that prints and runs npm/node + +"""Node release smoke test, called by node-release.yml after publishing. + +python3 scripts/node_dev.py smoke VERSION +""" + +import argparse +import json +import subprocess +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NODE_DIR = ROOT / "node" +EXPORTS = [ + "ConfigurationError", + "ElastiCacheIAMAuthTokenManager", + "ElastiCacheIAMAuthTokenProvider", + "InvalidParameterError", + "TokenRefreshError", + "ToolkitInputError", + "generateIamAuthToken", +] +# The registry's package listing has lagged 5+ minutes behind a publish. +INSTALL_ATTEMPTS = 20 +INSTALL_RETRY_SECONDS = 40 + + +def fail(message: str) -> None: + raise SystemExit(f"::error::{message}") + + +def smoke(version: str) -> None: + name = json.loads((NODE_DIR / "package.json").read_text())["name"] + spec = f"{name}@{version}" + with tempfile.TemporaryDirectory() as project: + Path(project, "package.json").write_text('{"private": true, "type": "module"}') + for attempt in range(1, INSTALL_ATTEMPTS + 1): + # Exact version, fresh cache, --prefer-online: a cached "not found" must + # not outlive the registry catching up. + with tempfile.TemporaryDirectory() as cache: + command = ["npm", "install", spec, "--no-audit", "--no-fund"] + command += ["--ignore-scripts", "--prefer-online", "--cache", cache] + result = subprocess.run(command, cwd=project, check=False) + if result.returncode == 0: + break + if attempt == INSTALL_ATTEMPTS: + fail(f"Could not install {spec} after {INSTALL_ATTEMPTS} attempts.") + print( + f"install attempt {attempt}/{INSTALL_ATTEMPTS} failed (registry may lag " + f"the upload); retrying in {INSTALL_RETRY_SECONDS}s" + ) + time.sleep(INSTALL_RETRY_SECONDS) + + manifest = Path(project, "node_modules", *name.split("/"), "package.json") + installed = json.loads(manifest.read_text())["version"] + if installed != version: + fail(f"Installed {name}@{installed}, expected {version}.") + probe = ( + f"import * as pkg from {json.dumps(name)};" + f"const missing = {json.dumps(EXPORTS)}.filter((n) => !(n in pkg));" + "if (missing.length) { console.error(missing.join(', ')); process.exit(1); }" + ) + check = subprocess.run( + ["node", "--input-type=module", "-e", probe], + cwd=project, + capture_output=True, + text=True, + check=False, + ) + if check.returncode != 0: + fail(f"Published package is missing exports: {check.stderr.strip()}") + print(f"Smoke test passed for {spec}.") + + +def main() -> None: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + smoke_parser = commands.add_parser("smoke") + smoke_parser.add_argument("version") + args = parser.parse_args() + + if args.command == "smoke": + smoke(args.version) + + +if __name__ == "__main__": + main() diff --git a/scripts/python_dev.py b/scripts/python_dev.py index ee6633b..dda2ef5 100755 --- a/scripts/python_dev.py +++ b/scripts/python_dev.py @@ -36,6 +36,8 @@ PACKAGE = "developer-toolkit-elasticache" IMPORT_NAME = "developer_toolkit_elasticache" DO_NOT_UPLOAD = "Private :: Do Not Upload" +# Only release versions (X.Y.Z) are published; no prereleases. +_RELEASE_VERSION = re.compile(r"\d+\.\d+\.\d+") _COMMANDS = { @@ -150,6 +152,12 @@ def verify(args: argparse.Namespace) -> int: f"({pkg_version}). Bump the version and re-tag." ) return 1 + if not _RELEASE_VERSION.fullmatch(pkg_version): + print( + f"::error::Version {pkg_version} is a prerelease. Only release versions " + "(X.Y.Z) are published." + ) + return 1 if DO_NOT_UPLOAD in project.get("classifiers", []): print( f"::error::The '{DO_NOT_UPLOAD}' classifier is still set. Remove it in the "