Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/node-release.yml
Original file line number Diff line number Diff line change
@@ -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}"
4 changes: 2 additions & 2 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions node/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules/

# npm
*.tgz
pack/
npm-debug.log*

# Test / coverage output
Expand Down
11 changes: 11 additions & 0 deletions node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -19,6 +26,9 @@
"NOTICE",
"README.md"
],
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand All @@ -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"
Expand Down
118 changes: 118 additions & 0 deletions node/scripts/verify.mjs
Original file line number Diff line number Diff line change
@@ -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 <version>]
//
// 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 ?? "";
Comment thread
edlng marked this conversation as resolved.

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`);
Loading
Loading