Skip to content
Open
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
66 changes: 66 additions & 0 deletions .github/workflows/publish-packages.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: "Publish packages"

on:
push:
tags:
- "shared-utils-v*"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read
packages: write

jobs:
publish-shared-utils:
name: "Publish @nhsdigital/nhs-notify-shared-utils"
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: "Set CI/CD variables"
id: variables
run: |
echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT"
echo "pnpm_version=$(grep "^pnpm\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT"

- name: "Node install and setup"
uses: ./.github/actions/node-install
with:
node-version: ${{ steps.variables.outputs.nodejs_version }}
pnpm-version: ${{ steps.variables.outputs.pnpm_version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: "Verify tag version matches package version"
run: |
TAG_VERSION="${GITHUB_REF_NAME#shared-utils-v}"
PKG_VERSION="$(node -p "require('./packages/shared-utils/package.json').version")"
echo "Tag version: ${TAG_VERSION}"
echo "Package version: ${PKG_VERSION}"
if [ "${TAG_VERSION}" != "${PKG_VERSION}" ]; then
echo "::error::Tag version (${TAG_VERSION}) does not match package version (${PKG_VERSION})" >&2
exit 1
fi

- name: "Install dependencies"
run: pnpm install --frozen-lockfile

- name: "Lint"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run lint

- name: "Typecheck"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run typecheck

- name: "Unit tests (100% coverage)"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run test:unit

- name: "Build"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils run build

- name: "Publish to GitHub Packages"
run: pnpm --filter @nhsdigital/nhs-notify-shared-utils publish --no-git-checks
51 changes: 51 additions & 0 deletions .github/workflows/tag-shared-utils-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: "Tag shared-utils release"

on:
workflow_run:
workflows: ["1. CI/CD pull request"]
types: ["completed"]

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

permissions:
contents: write

jobs:
tag-release:
name: "Tag @nhsdigital/nhs-notify-shared-utils release"
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: "Set CI/CD variables"
id: variables
run: |
echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT"

- name: "Use Node.js"
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ steps.variables.outputs.nodejs_version }}

- name: "Tag release if not already tagged"
run: |
PKG_VERSION="$(node -p "require('./packages/shared-utils/package.json').version")"
TAG="shared-utils-v${PKG_VERSION}"

if git rev-parse -q --verify "refs/tags/${TAG}" > /dev/null; then
echo "Tag ${TAG} already exists, skipping."
exit 0
fi

echo "Creating tag ${TAG} at ${{ github.event.workflow_run.head_sha }}"
git tag "${TAG}" "${{ github.event.workflow_run.head_sha }}"
git push origin "${TAG}"
3 changes: 2 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig([
project: [
'src/lambdas/*/tsconfig.json',
'src/utils/tsconfig.json',
'packages/*/tsconfig.json',
],
}),
],
Expand Down Expand Up @@ -217,7 +218,7 @@ export default defineConfig([
},
},
{
files: ['src/utils/**', '**/jest.config.ts'],
files: ['src/utils/**', '**/jest.config.ts', 'packages/**'],
rules: {
'no-relative-import-paths/no-relative-import-paths': 0,
'import-x/no-relative-packages': 0,
Expand Down
33 changes: 33 additions & 0 deletions packages/shared-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# @nhsdigital/nhs-notify-shared-utils

This package contains **generic** technical helpers (logging, Lambda
helpers) for use across bounded contexts. Test-only helpers (AWS client
factories, polling, event-factory fixtures) are intentionally not included —
they are boilerplate enough to copy into each repo's integration tests rather
than maintain as a shared dependency.

## Exports

| Subpath | Purpose | Docs |
| ------------------ | ------------------------------------------------------------------ | ------------------------------------------- |
| `./logger` | Generic pino-backed `Logger` with redaction support | [logger](src/logger/README.md) |
| `./lambda-utils` | `parseEnv`, `EnvValidationError`, `formatZodIssues`, SQS attribute readers | [lambda-utils](src/lambda-utils/README.md) |
| `./s3-json` | S3 get-JSON-and-validate helper | [s3-json](src/s3-json/README.md) |

## Scripts

```sh
pnpm run build # rm -rf dist && tsc
pnpm run lint # eslint .
pnpm run typecheck # tsc --noEmit
pnpm run test:unit # jest (100% coverage)
pnpm run verify # lint && typecheck && test:unit
```

## Release

Publishing is tag-driven: pushing a tag of the form `shared-utils-vX.Y.Z`
triggers the publish workflow. Tags are created automatically by the
"Tag shared-utils release" workflow once CI/CD completes successfully on
`main`, from whatever `version` is set in `package.json` — so bumping the
version in a merged PR is the only manual step required to release.
22 changes: 22 additions & 0 deletions packages/shared-utils/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Config } from 'jest';
import { baseJestConfig } from '../../jest.config.base';

const sharedUtilsJestConfig: Config = {
...baseJestConfig,

coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},

coveragePathIgnorePatterns: [
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
'index.ts',
],
};

export default sharedUtilsJestConfig;
69 changes: 69 additions & 0 deletions packages/shared-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@nhsdigital/nhs-notify-shared-utils",
"version": "0.1.0",
"description": "Generic technical utilities (logging, lambda helpers) shared across NHS Notify bounded contexts",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"source": "./src/index.ts",
"default": "./dist/index.js"
},
"./lambda-utils": {
"types": "./dist/lambda-utils/index.d.ts",
"source": "./src/lambda-utils/index.ts",
"default": "./dist/lambda-utils/index.js"
},
"./logger": {
"types": "./dist/logger/index.d.ts",
"source": "./src/logger/index.ts",
"default": "./dist/logger/index.js"
},
"./s3-json": {
"types": "./dist/s3-json/index.d.ts",
"source": "./src/s3-json/index.ts",
"default": "./dist/s3-json/index.js"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:unit": "jest",
"typecheck": "tsc --noEmit",
"verify": "pnpm run lint && pnpm run typecheck && pnpm run test:unit"
},
"peerDependencies": {
"@aws-sdk/client-s3": "catalog:aws",
"pino": "catalog:runtime"
},
"peerDependenciesMeta": {
"@aws-sdk/client-s3": {
"optional": true
}
},
"devDependencies": {
"@aws-sdk/client-s3": "catalog:aws",
"@tsconfig/node22": "catalog:tools",
"@types/jest": "catalog:test",
"@types/node": "catalog:tools",
"eslint": "catalog:lint",
"jest": "catalog:test",
"pino": "catalog:runtime",
"ts-jest": "catalog:test",
"typescript": "catalog:tools"
},
"engines": {
"node": ">=22.15.1"
},
"private": false
}
2 changes: 2 additions & 0 deletions packages/shared-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lambda-utils';
export * from './logger';
62 changes: 62 additions & 0 deletions packages/shared-utils/src/lambda-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# `@nhsdigital/nhs-notify-shared-utils/lambda-utils`

Small, dependency-light helpers for Lambda handlers.

## Import

```ts
import {
CORRELATION_ID_ATTRIBUTE,
EnvValidationError,
formatZodIssues,
parseEnv,
readSqsStringAttribute,
} from '@nhsdigital/nhs-notify-shared-utils/lambda-utils';
```

## `parseEnv`

Parses `process.env` (or a supplied source) against a Zod object schema,
returning a typed, coerced result. Throws `EnvValidationError` — with a
formatted list of issues — when validation fails.

```ts
import { z } from 'zod';

const envSchema = z.object({
TABLE_NAME: z.string().min(1),
TTL_SECONDS: z.coerce.number().int().positive(),
});

const { TABLE_NAME: tableName, TTL_SECONDS: ttlSeconds } = parseEnv(envSchema);
```

`EnvSchema<T>` declares the minimal `safeParse` shape required, so this
package does not depend on Zod directly — any Zod object schema is
structurally assignable to it.

## `readSqsStringAttribute`

Reads a `String` message attribute from an SQS record, or `undefined` when the
attribute is absent or not a string. `CORRELATION_ID_ATTRIBUTE` is the shared
`correlationId` attribute name.

```ts
const correlationId = readSqsStringAttribute(record, CORRELATION_ID_ATTRIBUTE);
```

The record only needs a `messageAttributes` map (see `SqsRecordLike`), so the
helper stays decoupled from the `aws-lambda` types.

## `formatZodIssues`

Turns an array of Zod issues into a single readable string. It is pure: it does
not log or throw. Any `ZodError.issues` array is accepted.

```ts
const result = schema.safeParse(input);
if (!result.success) {
throw new Error(`Invalid input — ${formatZodIssues(result.error.issues)}`);
}
// "items.0.id: Required; name: Expected string"
```
68 changes: 68 additions & 0 deletions packages/shared-utils/src/lambda-utils/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type EnvSchema, EnvValidationError, parseEnv } from '../env';

interface Config {
tableName: string;
ttlSeconds: number;
}

function createSchema(overrides: Partial<Config> = {}): EnvSchema<Config> {
return {
safeParse: (data) => {
const source = data as Record<string, string | undefined>;
const tableName = overrides.tableName ?? source.TABLE_NAME;
const ttlSecondsRaw = source.TTL_SECONDS;

const issues = [];
if (!tableName) {
issues.push({ path: ['TABLE_NAME'], message: 'Required' });
}
if (!ttlSecondsRaw || Number.isNaN(Number(ttlSecondsRaw))) {
issues.push({ path: ['TTL_SECONDS'], message: 'Expected number' });
}

if (issues.length > 0) {
return { success: false, error: { issues } };
}

return {
success: true,
data: {
tableName: tableName as string,
ttlSeconds: Number(ttlSecondsRaw),
},
};
},
};
}

describe('parseEnv', () => {
it('returns the parsed value when the schema validates', () => {
const schema = createSchema();
const result = parseEnv(schema, {
TABLE_NAME: 'my-table',
TTL_SECONDS: '60',
});
expect(result).toEqual({ tableName: 'my-table', ttlSeconds: 60 });
});

it('defaults the source to process.env', () => {
const ORIGINAL_ENV = { ...process.env };
process.env.TABLE_NAME = 'from-process-env';
process.env.TTL_SECONDS = '30';

expect(parseEnv(createSchema())).toEqual({
tableName: 'from-process-env',
ttlSeconds: 30,
});

process.env = { ...ORIGINAL_ENV };
});

it('throws EnvValidationError with formatted issues when validation fails', () => {
const schema = createSchema();
expect(() => parseEnv(schema, {})).toThrow(EnvValidationError);
expect(() => parseEnv(schema, {})).toThrow(
'Invalid environment configuration — TABLE_NAME: Required; TTL_SECONDS: Expected number',
);
});
});
Loading
Loading