@devopsplaybook.io/common-utils 1.6.0-beta.15.02b378d → 1.7.0-beta.16.a8b6bbc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,17 +4,17 @@ on:
4
4
  workflow_call:
5
5
  inputs:
6
6
  docker_platforms:
7
- description: "Docker platforms to build for"
7
+ description: "Docker platforms, used only by the fallback build when no PR image can be promoted"
8
8
  required: false
9
9
  type: string
10
10
  default: "linux/arm64/v8,linux/amd64"
11
11
  node_app_directories:
12
- description: "JSON array of Node.js app directories to build, lint, and test"
12
+ description: "Deprecated and unused. Kept so existing callers stay valid: the merge build runs no build, lint or test."
13
13
  required: false
14
14
  type: string
15
15
  default: ""
16
16
  node_version:
17
- description: "Node.js version to use"
17
+ description: "Deprecated and unused. Kept so existing callers stay valid: the merge build runs no Node.js job."
18
18
  required: false
19
19
  type: string
20
20
  default: "22"
@@ -28,114 +28,152 @@ on:
28
28
  QUALITY_DASHBOARD_URL:
29
29
  required: false
30
30
 
31
+ # Immutable build strategy: the image validated on the pull request is promoted
32
+ # as-is, so merging runs no build, lint or test. `pull-requests: read` is needed
33
+ # to resolve which PR produced the commit pushed to the default branch.
34
+ permissions:
35
+ contents: read
36
+ pull-requests: read
37
+
31
38
  jobs:
32
- node-build:
33
- if: inputs.node_app_directories != ''
34
- strategy:
35
- matrix:
36
- app: ${{ fromJSON(inputs.node_app_directories) }}
39
+ promote-image:
37
40
  runs-on: ubuntu-latest
38
- defaults:
39
- run:
40
- working-directory: ${{ matrix.app }}
41
41
  steps:
42
42
  - uses: actions/checkout@v7
43
43
 
44
- - name: Setup Node.js
45
- uses: actions/setup-node@v7
46
- with:
47
- node-version: ${{ inputs.node_version }}
48
- cache: "npm"
49
- cache-dependency-path: ${{ matrix.app }}/package-lock.json
50
-
51
- - name: Install dependencies
52
- run: npm ci
53
-
54
- - name: Build
55
- run: npm run build
44
+ - name: Set up QEMU
45
+ uses: docker/setup-qemu-action@v4
56
46
 
57
- - name: Lint
58
- run: npm run lint
47
+ - name: Set up Docker Buildx
48
+ uses: docker/setup-buildx-action@v4
59
49
 
60
- - name: Test
61
- run: npm run test
50
+ - name: Login to Docker Hub
51
+ uses: docker/login-action@v4
52
+ with:
53
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
54
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
62
55
 
63
- - name: Zip coverage and upload to Quality Dashboard
64
- if: always()
56
+ - name: Resolve the image validated by the merged PR
57
+ id: resolve
65
58
  env:
66
- QUALITY_DASHBOARD_URL: ${{ secrets.QUALITY_DASHBOARD_URL }}
67
- QUALITY_DASHBOARD_TOKEN: ${{ secrets.QUALITY_DASHBOARD_TOKEN }}
59
+ GH_TOKEN: ${{ github.token }}
60
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
68
61
  run: |
69
- APP_NAME="${{ matrix.app }}"
70
- PACKAGE_NAME=$(node -p "require('./package.json').name")
71
- REPORT_KEY="$(echo "$APP_NAME" | sed 's/[^a-zA-Z0-9._:\-\/]/_/g')_coverage"
72
-
73
- if [ -d coverage ]; then
74
- if [ -z "$QUALITY_DASHBOARD_URL" ] || [ -z "$QUALITY_DASHBOARD_TOKEN" ]; then
75
- echo "Quality Dashboard URL or token not set, skipping upload"
76
- exit 0
62
+ set -e
63
+ SERVICE_NAME=$(jq -r '.name' package.json)
64
+ SERVICE_VERSION=$(jq -r '.version' package.json)
65
+ IMAGE="${DOCKER_HUB_USERNAME}/${SERVICE_NAME}"
66
+
67
+ # Find the PR that produced this commit. Works for squash, rebase and
68
+ # merge commits.
69
+ PR_NUMBER=$(curl -sfL \
70
+ -H "Authorization: Bearer ${GH_TOKEN}" \
71
+ -H "Accept: application/vnd.github+json" \
72
+ "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" \
73
+ | jq -r '.[0].number // empty' || true)
74
+
75
+ # Fallback: read the PR number from the commit message, either
76
+ # "<title> (#123)" for squash/rebase or "Merge pull request #123".
77
+ if [ -z "${PR_NUMBER}" ]; then
78
+ MESSAGE=$(git log -1 --pretty=%B)
79
+ PR_NUMBER=$(printf '%s' "${MESSAGE}" | grep -oE '\(#[0-9]+\)' | tail -1 | tr -d '(#)' || true)
80
+ if [ -z "${PR_NUMBER}" ]; then
81
+ PR_NUMBER=$(printf '%s' "${MESSAGE}" | grep -oE 'Merge pull request #[0-9]+' | grep -oE '[0-9]+' | tail -1 || true)
77
82
  fi
78
- # Zip coverage contents at root level so the jest processor
79
- # finds coverage-final.json / clover.xml directly
80
- (cd coverage && zip -r "${{ runner.temp }}/coverage.zip" .)
81
-
82
- META=$(jq -n \
83
- --arg key "$REPORT_KEY" \
84
- --arg pkg "$PACKAGE_NAME" \
85
- '{
86
- key: $key,
87
- displayName: "Coverage: \($pkg)",
88
- processor: "jest"
89
- }')
90
-
91
- echo "Uploading coverage: $REPORT_KEY"
92
- HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
93
- -X POST "$QUALITY_DASHBOARD_URL/api/reports/" \
94
- -H "x-upload-token: $QUALITY_DASHBOARD_TOKEN" \
95
- -F "meta=$META" \
96
- -F "file=@${{ runner.temp }}/coverage.zip" \
97
- --max-time 30)
98
-
99
- if [ "$HTTP_CODE" = "201" ]; then
100
- echo "\u2713 Coverage uploaded ($HTTP_CODE)"
83
+ fi
84
+
85
+ SOURCE_IMAGE=""
86
+ if [ -z "${PR_NUMBER}" ]; then
87
+ echo "::warning::No pull request found for ${GITHUB_SHA}, falling back to a build."
88
+ else
89
+ CANDIDATE="${IMAGE}:beta-pr-${PR_NUMBER}"
90
+ if docker buildx imagetools inspect "${CANDIDATE}" > /dev/null 2>&1; then
91
+ SOURCE_IMAGE="${CANDIDATE}"
101
92
  else
102
- echo "\u26a0 Upload returned HTTP $HTTP_CODE"
93
+ echo "::warning::${CANDIDATE} not found in the registry, falling back to a build."
103
94
  fi
104
- else
105
- echo "No coverage/ directory found, skipping upload"
106
95
  fi
107
96
 
108
- docker-build:
109
- runs-on: ubuntu-latest
110
- steps:
111
- - uses: actions/checkout@v7
97
+ if [ -n "${SOURCE_IMAGE}" ]; then
98
+ STRATEGY="retag"
99
+ else
100
+ STRATEGY="build"
101
+ fi
112
102
 
113
- - name: Set up QEMU
114
- uses: docker/setup-qemu-action@v4
103
+ {
104
+ echo "image=${IMAGE}"
105
+ echo "service_name=${SERVICE_NAME}"
106
+ echo "service_version=${SERVICE_VERSION}"
107
+ echo "pr_number=${PR_NUMBER}"
108
+ echo "source_image=${SOURCE_IMAGE}"
109
+ echo "strategy=${STRATEGY}"
110
+ } >> "${GITHUB_OUTPUT}"
115
111
 
116
- - name: Set up Docker Buildx
117
- uses: docker/setup-buildx-action@v4
112
+ echo "Service: ${SERVICE_NAME} ${SERVICE_VERSION}"
113
+ echo "Strategy: ${STRATEGY}"
118
114
 
119
- - name: Login to Docker Hub
120
- uses: docker/login-action@v4
121
- with:
122
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
123
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
115
+ - name: Promote the PR image to the release tags
116
+ if: steps.resolve.outputs.strategy == 'retag'
117
+ env:
118
+ RELEASE_IMAGE: ${{ steps.resolve.outputs.image }}
119
+ RELEASE_VERSION: ${{ steps.resolve.outputs.service_version }}
120
+ SOURCE_IMAGE: ${{ steps.resolve.outputs.source_image }}
121
+ MERGED_PR_NUMBER: ${{ steps.resolve.outputs.pr_number }}
122
+ run: |
123
+ set -e
124
+ MAJOR=$(echo "${RELEASE_VERSION}" | cut -f1 -d".")
125
+ MINOR=$(echo "${RELEASE_VERSION}" | cut -f1-2 -d".")
126
+
127
+ SOURCE_DIGEST=$(docker buildx imagetools inspect "${SOURCE_IMAGE}" --format '{{.Manifest.Digest}}')
128
+
129
+ echo "Promoting ${SOURCE_IMAGE} (PR #${MERGED_PR_NUMBER})"
130
+ echo " digest ${SOURCE_DIGEST}"
131
+ echo " -> ${RELEASE_IMAGE}:${RELEASE_VERSION}"
132
+ echo " -> ${RELEASE_IMAGE}:${MAJOR}"
133
+ echo " -> ${RELEASE_IMAGE}:${MINOR}"
134
+ echo " -> ${RELEASE_IMAGE}:latest"
135
+
136
+ # --prefer-index=false forces a carbon copy of the source manifest, so
137
+ # the released image keeps the exact digest validated on the pull
138
+ # request. Without it, a single-platform source would be rewrapped into
139
+ # a new image index and the digest would change.
140
+ docker buildx imagetools create \
141
+ --prefer-index=false \
142
+ -t "${RELEASE_IMAGE}:${RELEASE_VERSION}" \
143
+ -t "${RELEASE_IMAGE}:${MAJOR}" \
144
+ -t "${RELEASE_IMAGE}:${MINOR}" \
145
+ -t "${RELEASE_IMAGE}:latest" \
146
+ "${SOURCE_IMAGE}"
147
+
148
+ # Fail loudly rather than publish an artifact that differs from the one
149
+ # tested on the pull request.
150
+ for TAG in "${RELEASE_VERSION}" "${MAJOR}" "${MINOR}" "latest"; do
151
+ PROMOTED_DIGEST=$(docker buildx imagetools inspect "${RELEASE_IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')
152
+ if [ "${PROMOTED_DIGEST}" != "${SOURCE_DIGEST}" ]; then
153
+ echo "::error::${RELEASE_IMAGE}:${TAG} has digest ${PROMOTED_DIGEST}, expected ${SOURCE_DIGEST}"
154
+ exit 1
155
+ fi
156
+ echo " verified ${RELEASE_IMAGE}:${TAG} -> ${PROMOTED_DIGEST}"
157
+ done
124
158
 
125
- - name: Build and Push Docker Image
159
+ - name: Build and Push Docker Image (fallback)
160
+ if: steps.resolve.outputs.strategy == 'build'
161
+ env:
162
+ RELEASE_IMAGE: ${{ steps.resolve.outputs.image }}
163
+ RELEASE_VERSION: ${{ steps.resolve.outputs.service_version }}
164
+ DOCKER_PLATFORMS: ${{ inputs.docker_platforms }}
126
165
  run: |
127
166
  set -e
128
- SERVICE_NAME=$(cat package.json | jq -r '.name')
129
- SERVICE_VERSION=$(cat package.json | jq -r '.version')
130
- SERVICE_VERSION_MAJOR=$(echo "${SERVICE_VERSION}" | cut -f1 -d".")
131
- SERVICE_VERSION_MINOR=$(echo "${SERVICE_VERSION}" | cut -f1-2 -d".")
132
- echo "Building ${SERVICE_NAME}:${SERVICE_VERSION}"
167
+ MAJOR=$(echo "${RELEASE_VERSION}" | cut -f1 -d".")
168
+ MINOR=$(echo "${RELEASE_VERSION}" | cut -f1-2 -d".")
169
+
170
+ echo "Building ${RELEASE_IMAGE}:${RELEASE_VERSION} from ${GITHUB_SHA}"
133
171
  docker buildx build \
134
- --platform ${{ inputs.docker_platforms }} \
172
+ --platform "${DOCKER_PLATFORMS}" \
135
173
  --push \
136
174
  -f Dockerfile \
137
- -t ${{ secrets.DOCKER_HUB_USERNAME }}/${SERVICE_NAME}:latest \
138
- -t ${{ secrets.DOCKER_HUB_USERNAME }}/${SERVICE_NAME}:${SERVICE_VERSION} \
139
- -t ${{ secrets.DOCKER_HUB_USERNAME }}/${SERVICE_NAME}:${SERVICE_VERSION_MAJOR} \
140
- -t ${{ secrets.DOCKER_HUB_USERNAME }}/${SERVICE_NAME}:${SERVICE_VERSION_MINOR} \
175
+ -t "${RELEASE_IMAGE}:latest" \
176
+ -t "${RELEASE_IMAGE}:${RELEASE_VERSION}" \
177
+ -t "${RELEASE_IMAGE}:${MAJOR}" \
178
+ -t "${RELEASE_IMAGE}:${MINOR}" \
141
179
  .
@@ -123,13 +123,28 @@ jobs:
123
123
  password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
124
124
 
125
125
  - name: Build and Push Docker Image
126
+ env:
127
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
128
+ DOCKER_PLATFORMS: ${{ inputs.docker_platforms }}
129
+ PR_NUMBER: ${{ github.event.pull_request.number }}
126
130
  run: |
127
131
  set -e
128
- SERVICE_NAME=$(cat package.json | jq -r '.name')
129
- echo "Building ${SERVICE_NAME}:beta"
132
+ SERVICE_NAME=$(jq -r '.name' package.json)
133
+ IMAGE="${DOCKER_HUB_USERNAME}/${SERVICE_NAME}"
134
+
135
+ # `beta-pr-<number>` is the immutable per-PR reference: it identifies
136
+ # exactly the artifact validated by this pull request, and the merge
137
+ # build promotes it as-is to the semantic version tags.
138
+ PR_TAG="${PR_NUMBER}"
139
+ if [ -z "${PR_TAG}" ]; then
140
+ PR_TAG="manual-${GITHUB_SHA::7}"
141
+ fi
142
+
143
+ echo "Building ${IMAGE}:beta-pr-${PR_TAG} and ${IMAGE}:beta"
130
144
  docker buildx build \
131
- --platform ${{ inputs.docker_platforms }} \
145
+ --platform "${DOCKER_PLATFORMS}" \
132
146
  --push \
133
147
  -f Dockerfile \
134
- -t ${{ secrets.DOCKER_HUB_USERNAME }}/${SERVICE_NAME}:beta \
148
+ -t "${IMAGE}:beta-pr-${PR_TAG}" \
149
+ -t "${IMAGE}:beta" \
135
150
  .
package/AGENTS.md CHANGED
@@ -38,8 +38,8 @@ src/
38
38
  reusable-npm-merge.yml # Lint + test + build + publish release to npm
39
39
  reusable-npm-pr.yml # Lint + test + build + publish beta tag + comment PR
40
40
  reusable-npm-upgrade.yml # npm-check-updates + auto PR
41
- reusable-pr-verify.yml # Matrix Node.js + multi-platform Docker build for PRs
42
- reusable-merge-build.yml # Matrix Node.js + Docker build with version tags on merge
41
+ reusable-pr-verify.yml # Matrix Node.js + multi-platform Docker build -> beta-pr-<PR> and beta
42
+ reusable-merge-build.yml # Promotes the PR image to the version tags on merge (no build/lint/test)
43
43
  ```
44
44
 
45
45
  ## Key Conventions
@@ -53,6 +53,7 @@ src/
53
53
  - **ModuleLogger pattern**: `StandardLogger` only exposes `createModuleLogger(name)`. DB modules call `logger.createModuleLogger("ModuleName")` internally. Never call `.info()` or `.error()` directly on a `StandardLogger`.
54
54
  - **SQLite-first SQL**: Write SQL with `?` placeholders. The `DbUtils` facade and `DbUtilsNoTelemetry` module auto-convert to `$1, $2, ...` for Postgres via `convertToPostgresPlaceholders()`.
55
55
  - **Migration convention**: SQL files named `init-NNNN.sql`. `init-0000.sql` must create the `metadata` table. Subsequent files are applied in lexicographic order; applied versions are tracked in `metadata` for idempotency.
56
+ - **Immutable Docker builds**: images are built once on the pull request (`reusable-pr-verify.yml` pushes `beta-pr-<PR number>` and `beta`) and promoted unchanged on merge (`reusable-merge-build.yml` writes `<version>`, `<major>`, `<minor>` and `latest`). The merge workflow runs no build, lint or test: it resolves the merged PR, carbon-copies the manifest with `docker buildx imagetools create --prefer-index=false`, then reads back each published digest and fails on a mismatch. It falls back to a full build only when no `beta-pr-<PR number>` image can be promoted. Image name and version come from the root `package.json`, and PR branches must be up to date with the default branch before merging.
56
57
 
57
58
  ## Build and Verification
58
59
 
package/README.md CHANGED
@@ -484,8 +484,8 @@ The `.github/workflows/` directory contains **reusable workflows** that other re
484
484
  | **NPM Merge** | `reusable-npm-merge.yml` | `workflow_call` | Lint, test, build, and publish a release to npm on merge to main. Uploads coverage to Quality Dashboard. Only publishes if the version doesn't already exist. |
485
485
  | **NPM PR** | `reusable-npm-pr.yml` | `workflow_call` | Lint, test, and build on PR. Publishes a **beta** version tagged `beta` and comments the PR with install instructions. |
486
486
  | **NPM Upgrade** | `reusable-npm-upgrade.yml` | `workflow_call` | Runs `npm-check-updates -u`, bumps the patch version, and opens a PR. Supports monorepo sub-folders via `npm_services` input. |
487
- | **PR Verify** | `reusable-pr-verify.yml` | `workflow_call` | Matrix build/lint/test for multiple Node.js apps plus multi-platform Docker build. For monorepos with Docker images. |
488
- | **Merge Build** | `reusable-merge-build.yml` | `workflow_call` | Same as PR Verify but on merge. Tags Docker images with `latest`, version, major, and minor tags. |
487
+ | **PR Verify** | `reusable-pr-verify.yml` | `workflow_call` | Matrix build/lint/test for multiple Node.js apps, plus a multi-platform Docker build pushed as `beta-pr-<PR number>` and `beta`. For monorepos with Docker images. |
488
+ | **Merge Build** | `reusable-merge-build.yml` | `workflow_call` | Promotes the image validated by the merged PR to the `latest`, version, major and minor tags. Runs no build, lint or test. |
489
489
 
490
490
  ### Inputs and Secrets
491
491
 
@@ -517,6 +517,8 @@ The `.github/workflows/` directory contains **reusable workflows** that other re
517
517
  | `node_app_directories` | No | `""` | JSON array of Node.js app directories |
518
518
  | `node_version` | No | `"22"` | Node.js version |
519
519
 
520
+ `reusable-merge-build` declares the same inputs so existing callers keep working, but it only reads `docker_platforms`, and only on the build fallback path. `node_app_directories` and `node_version` are ignored there because merging runs no Node.js job.
521
+
520
522
  | Secret | Required | Description |
521
523
  | ------------------------- | -------- | ------------------------------ |
522
524
  | `DOCKER_HUB_USERNAME` | Yes | Docker Hub username |
@@ -524,6 +526,29 @@ The `.github/workflows/` directory contains **reusable workflows** that other re
524
526
  | `QUALITY_DASHBOARD_URL` | No | Quality Dashboard URL |
525
527
  | `QUALITY_DASHBOARD_TOKEN` | No | Quality Dashboard upload token |
526
528
 
529
+ `reusable-merge-build` also still declares the Quality Dashboard secrets for backward compatibility, but no longer uploads coverage: each report is produced once, on the pull request.
530
+
531
+ ### Immutable Docker Build Strategy
532
+
533
+ Docker images are built **once**, on the pull request, and **promoted** on merge. The artifact you test is bit-for-bit the artifact you release.
534
+
535
+ | Stage | Workflow | Registry tags written |
536
+ | ---------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------- |
537
+ | Pull request | `reusable-pr-verify` | `beta-pr-<PR number>` (identifies this PR's artifact) and `beta` (moving pointer to the latest PR build) |
538
+ | Merge to default | `reusable-merge-build` | `<version>`, `<major>`, `<minor>` and `latest`, copied from `beta-pr-<PR number>` |
539
+
540
+ On merge the workflow:
541
+
542
+ 1. Resolves the pull request behind the commit pushed to the default branch through `GET /repos/{owner}/{repo}/commits/{sha}/pulls`, falling back to the PR number in the commit message (`<title> (#123)` for squash and rebase, `Merge pull request #123` for merge commits).
543
+ 2. Carbon-copies the manifest with `docker buildx imagetools create --prefer-index=false`, which preserves the exact digest and the full multi-platform image index.
544
+ 3. Reads back the digest of every tag it published and fails if one differs from the source.
545
+
546
+ No `npm ci`, build, lint or test runs on merge, and no Docker build happens unless promotion is impossible. The build fallback keeps releases working when a commit reaches the default branch with no matching `beta-pr-<PR number>` image, such as a direct push or a PR whose image was deleted from the registry.
547
+
548
+ The image name and version come from the root `package.json`, so bump the minor version in the pull request that carries the change.
549
+
550
+ > **Keep PR branches up to date with the default branch before merging.** The promoted image is the one built from the PR head, so if the default branch moved in the meantime the released artifact will not contain those commits.
551
+
527
552
  ### Adopting in Your Project
528
553
 
529
554
  Create a caller workflow in `.github/workflows/main-build.yml`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.6.0-beta.15.02b378d",
3
+ "version": "1.7.0-beta.16.a8b6bbc",
4
4
  "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, auth/users, notifications, LLM, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",