From 808f3a2443231b369a8798242bbde95eb4d2bff6 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Tue, 6 Oct 2026 11:22:37 +0200 Subject: [PATCH 1/3] feat(cli): installs the beplus CLI the repository pins, via `be auto` (BE-213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI release must never change what a repository's CI does without a commit in that repository, and `cli` defaulted to `latest`. Now, with no `BE_CLI_VERSION`, it installs the version pinned in the nearest beplus.estate.json → cli.version by running `be auto` — the same command a laptop, a Docker build stage or a buildspec runs, so the pin is resolved in one place — and then fails unless `beplus --version` is exactly that pin. An inexact or unreadable pin fails the run; it never falls back to `latest`. An explicit `BE_CLI_VERSION` still wins (with a warning when it differs from the pin). With no pin the action keeps today's behaviour, `be latest`, and says with a `::warning::` that the repository floats. `BE_WORKING_DIRECTORY` says where the nearest manifest is looked up from (default: the workspace). `cdk-package` installs the CLI through `cli`; its `cli-version` default goes from `latest` to empty so it honours the pin too (its note about `latest` being v1 is stale: latest is 2.11.0). The four reusable workflows stop passing an explicit `latest`, which would override a pin; `vars.BE_CLI_VERSION` still wins when it is set. `test-cli.yml` runs this branch's action against fixtures in test/cli: a pin installs exactly it (2.10.0 — not the newest release, so a run that floated cannot pass), also from a package directory below the manifest; BE_CLI_VERSION wins; no pin and no manifest float on latest; an inexact pin (`2`) fails the run. The pinned legs go green once `be auto` reads beplus.estate.json on beplus/be@main. Co-Authored-By: Igor Lamos --- .github/workflows/docs-pages.yml | 2 +- .github/workflows/library-ci.yml | 2 +- .github/workflows/library-publish.yml | 4 +- .github/workflows/provisioning.yml | 2 +- .github/workflows/test-cli.yml | 113 +++++++++++++++++++++++++ README.md | 9 +- cdk-package/action.yml | 13 +-- cli/README.md | 83 +++++++++++++++--- cli/action.yml | 116 ++++++++++++++++++++++++-- test/cli/inexact/beplus.estate.json | 9 ++ test/cli/pinned/beplus.estate.json | 9 ++ test/cli/unpinned/beplus.estate.json | 8 ++ 12 files changed, 339 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/test-cli.yml create mode 100644 test/cli/inexact/beplus.estate.json create mode 100644 test/cli/pinned/beplus.estate.json create mode 100644 test/cli/unpinned/beplus.estate.json diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 04aeff6..a80424a 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -212,7 +212,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install dependencies diff --git a/.github/workflows/library-ci.yml b/.github/workflows/library-ci.yml index 7428a00..586b983 100644 --- a/.github/workflows/library-ci.yml +++ b/.github/workflows/library-ci.yml @@ -122,7 +122,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install the Dependencies diff --git a/.github/workflows/library-publish.yml b/.github/workflows/library-publish.yml index 242064d..cef1278 100644 --- a/.github/workflows/library-publish.yml +++ b/.github/workflows/library-publish.yml @@ -170,7 +170,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install the Dependencies @@ -611,7 +611,7 @@ jobs: # ~/.npmrc. Nothing here installs, so no npm.beplus.cloud token is needed. - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} - name: Authenticate with AWS CodeArtifact run: beplus aws codeartifact auth --env "${BE_ENVIRONMENT}" diff --git a/.github/workflows/provisioning.yml b/.github/workflows/provisioning.yml index d7be707..f03a53d 100644 --- a/.github/workflows/provisioning.yml +++ b/.github/workflows/provisioning.yml @@ -57,7 +57,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - uses: beplus/setup-beplus/provisioning@v2 diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml new file mode 100644 index 0000000..f380483 --- /dev/null +++ b/.github/workflows/test-cli.yml @@ -0,0 +1,113 @@ +# The `cli` action installs the beplus CLI the repository pins. +# --------------------------------------------------------------- +# A CLI release must never change what a repository's CI does without a commit in +# that repository. So `cli` (and `cdk-package`, which calls it) installs the +# version pinned in the nearest beplus.estate.json → cli.version via `be auto`, +# and checks `beplus --version` against the pin before anything uses the CLI. +# +# Each leg runs THIS branch's action (`uses: ./cli`) on a fresh runner against a +# fixture under test/cli, and asserts what `beplus --version` reports. The pinned +# fixture pins 2.10.0 — not the newest release — so a run that floated on +# `be latest` instead of honouring the pin cannot pass by accident. +# +# `be` itself comes from beplus/be@main, so the pinned legs go green once +# `be auto` reads beplus.estate.json there. +name: Test cli + +on: + pull_request: + paths: + - cli/** + - cdk-package/** + - test/cli/** + - .github/workflows/test-cli.yml + push: + branches: [main] + paths: + - cli/** + - cdk-package/** + - test/cli/** + - .github/workflows/test-cli.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + pin: + name: ${{ matrix.case }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - case: a pin installs exactly the pinned version + dir: test/cli/pinned + expect: 2.10.0 + - case: the nearest manifest pins a package below it + dir: test/cli/pinned/packages/app + expect: 2.10.0 + - case: BE_CLI_VERSION wins over the pin + dir: test/cli/pinned + version: 2.9.0 + expect: 2.9.0 + - case: a manifest without a pin floats on latest + dir: test/cli/unpinned + expect: latest + - case: no manifest floats on latest + dir: "" + expect: latest + - case: an inexact pin fails the run + dir: test/cli/inexact + expect: failure + + steps: + - uses: actions/checkout@v4 + + # A package directory with no manifest of its own: `be auto` must walk up + # to test/cli/pinned, the way it does from packages/apps/* in an estate. + - name: Make a package directory below the pinned manifest + run: mkdir -p test/cli/pinned/packages/app + + - name: Setup beplus CLI + id: setup + uses: ./cli + continue-on-error: ${{ matrix.expect == 'failure' }} + with: + BE_CLI_VERSION: ${{ matrix.version }} + # Empty `dir` = a directory with no beplus.estate.json at or above it. + BE_WORKING_DIRECTORY: ${{ matrix.dir || runner.temp }} + + - name: Assert the installed version + shell: bash + env: + EXPECT: ${{ matrix.expect }} + OUTCOME: ${{ steps.setup.outcome }} + run: | + set -euo pipefail + + if [[ "$EXPECT" == failure ]]; then + if [[ "$OUTCOME" != failure ]]; then + echo "::error::The action accepted a pin that is not an exact version (outcome: $OUTCOME)." + exit 1 + fi + echo "The action refused the inexact pin, as it must." + exit 0 + fi + + if [[ "$EXPECT" == latest ]]; then + EXPECT="$(be --latest)" + EXPECT="${EXPECT#v}" + fi + + out="$(beplus --version)" + echo "$out" + installed="" + if [[ "$out" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?) ]]; then + installed="${BASH_REMATCH[1]}" + fi + if [[ "$installed" != "$EXPECT" ]]; then + echo "::error::beplus --version reports ${installed:-nothing}, expected $EXPECT." + exit 1 + fi + echo "beplus CLI $installed, as expected." diff --git a/README.md b/README.md index c200631..bb23cc7 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ Composite GitHub Action collection for setting up the beplus Environment in GitH ## Available Actions ### [`cli`](./cli/README.md) -Installs the beplus CLI (`@beplus/be` and `beplus`) with optional npm authentication. +Installs the beplus CLI (`@beplus/be` and `beplus`) with optional npm authentication — +the version the repository pins in `beplus.estate.json` → `cli.version` (`be auto`). +A repository without a pin gets `latest` and a warning that it floats; see +[Pinning the CLI](./cli/README.md#pinning-the-cli). ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: latest BE_NPM_AUTH: false ``` @@ -138,9 +140,8 @@ jobs: steps: - uses: actions/checkout@v4 + # The version pinned in beplus.estate.json → cli.version - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest - uses: beplus/setup-beplus/git@v2 diff --git a/cdk-package/action.yml b/cdk-package/action.yml index 05c1aa9..9dd0321 100644 --- a/cdk-package/action.yml +++ b/cdk-package/action.yml @@ -77,12 +77,13 @@ inputs: default: us-east-1 cli-version: description: >- - Which beplus CLI to install. ⚠️ `latest` resolves to the newest FULL - release, which today is v1 — and the resolve/prune steps below call - `beplus infra`, which only exists in v2. Callers on v2 must pass an - explicit version until a v2 release is tagged. + Which beplus CLI to install. Empty (the default) installs the version the + repository pins in beplus.estate.json → cli.version (`be auto`, via the + `cli` action); without a pin it floats on `latest`, with a warning. A value + here wins over the pin. The resolve/prune steps below call `beplus infra`, + which only exists in v2. required: false - default: latest + default: "" npm-token: description: BE_NPM_TOKEN. A composite action cannot read `secrets`, so the caller passes it. required: true @@ -113,6 +114,8 @@ runs: # @beplus/* lives on npm.beplus.cloud (routed by common/config/rush/.npmrc). # Without the token pnpm still gets HTTP 200 from the proxy — with a stub # body — and fails late and unhelpfully with ERR_PNPM_TARBALL_INTEGRITY. + # The same step installs the beplus CLI the repository pins (beplus.estate.json + # → cli.version, checked out above) unless `cli-version` overrides it. - name: Authenticate the @beplus scope uses: beplus/setup-beplus/cli@v2 env: diff --git a/cli/README.md b/cli/README.md index 470fe1d..9a87a56 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,14 +1,17 @@ # Setup beplus CLI (GitHub Action) -Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) and then installs the [**beplus CLI**](https://github.com/beplus/cli) (`beplus`) into a **writable prefix** (no `sudo` required). Optionally runs `beplus npm auth` for [beplus.cloud](https://beplus.cloud) package access. +Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) and then installs the [**beplus CLI**](https://github.com/beplus/cli) (`beplus`) into a **writable prefix** (no `sudo` required) — by default **the version the repository pins** in `beplus.estate.json`. Optionally runs `beplus npm auth` for [beplus.cloud](https://beplus.cloud) package access. --- ## What it does 1. Installs `be` using the official install script -2. Installs `beplus` via `be ` -3. Verifies `beplus --version` +2. Chooses the version: + - `BE_CLI_VERSION` set → `be ` (wins over a pin, with a warning when it differs from it) + - otherwise, the nearest `beplus.estate.json` has `cli.version` → `be auto`, which installs exactly that version + - otherwise → `be latest`, as before, with a `::warning::` that the repository floats +3. Verifies `beplus --version` — and, for a pinned run, fails unless it is exactly the pinned version 4. Optionally runs `beplus npm auth` if enabled and required environment variables are present --- @@ -17,9 +20,54 @@ Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) | Input | Required | Default | Description | |------|----------|---------|-------------| -| `BE_CLI_VERSION` | No | `latest` | Version or channel of beplus CLI to install (passed to `be`) | +| `BE_CLI_VERSION` | No | empty | Version or channel of beplus CLI to install (passed to `be`). Empty installs the repository's pin (`be auto`), or `latest` with a warning when there is none. A value here wins over the pin. | | `BE_NPM_AUTH` | No | `false` | If `true`, runs `beplus npm auth` | | `BE_PREFIX` | No | empty | Install prefix for beplus binaries. If empty, defaults to `$HOME/.beplus`. Must be writable. | +| `BE_WORKING_DIRECTORY` | No | the workspace | Where the pin is looked up: the nearest `beplus.estate.json` at or above this directory, exactly as `be auto` finds it. Relative to the workspace. | + +--- + +## Pinning the CLI + +A CLI release must never change what a repository's CI does without a commit in +that repository. So the repository commits the version it runs, in +`beplus.estate.json` at its root: + +```json +{ + "cli": { "version": "2.11.0" } +} +``` + +`version` is an exact version (`2.11.0`, not `2` or `latest`). With it, this +action runs `be auto` — the same command a laptop, a Docker build stage or a +buildspec runs, so every place resolves the pin the same way — and then checks +`beplus --version` against it. An inexact or unreadable pin fails the run; it +never falls back to `latest`. + +Check the repository out **before** this action, or there is no manifest to read +(and the run floats, with a warning). In a monorepo the nearest manifest at or +above `BE_WORKING_DIRECTORY` wins. + +[`cdk-package`](../cdk-package/action.yml) installs the CLI through this action +with an empty `cli-version` by default, so it honours the pin too. +[`test-cli.yml`](../.github/workflows/test-cli.yml) proves all of this on every +change, against the fixtures in [`test/cli`](../test/cli). + +### Bumping the pin + +One commit per repository, nothing else in it: + +1. Change `cli.version` in `beplus.estate.json` (e.g. `2.11.0` → `2.12.0`). +2. Open a pull request. Its CI installs the new version through this action and + proves it with `beplus --version`; the deploy path (`be auto` in Dockerfiles, + base images, buildspecs) picks the same version up from the same file. +3. Merge it on its own — not together with an unrelated change — so a + behaviour change it brings is attributable to the bump. + +A repository without a pin keeps working — it installs `latest` — but every run +says so with a warning, because every CLI release changes its CI without a +commit there. --- @@ -38,22 +86,22 @@ Required only when `BE_NPM_AUTH` is set to `"true"`: ## Usage -### Minimal usage +### Minimal usage — the version the repository pins ```yaml +- uses: actions/checkout@v4 + - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest ``` --- -### Install a specific version +### Install a specific version (overrides the pin) ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: v1.0.0-beta.3 + BE_CLI_VERSION: 2.11.0 ``` --- @@ -64,7 +112,6 @@ Provide `BE_NPM_TARGET` and `BE_NPM_TOKEN` via GitHub secrets or variables. ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: latest BE_NPM_AUTH: "true" env: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} @@ -95,9 +142,8 @@ jobs: with: node-version: 24 + # Installs the version pinned in beplus.estate.json → cli.version - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest - run: | beplus --version @@ -114,6 +160,19 @@ If you override `BE_PREFIX`, ensure it points to a writable directory. --- +### "This repository does not pin the beplus CLI" + +The run found no `cli.version` in the nearest `beplus.estate.json` (or no +manifest at all — is the repository checked out before this action?) and +installed `latest`. Add the pin; see [Pinning the CLI](#pinning-the-cli). + +### "pins beplus CLI X, but `be auto` installed Y" + +The pin is not an exact version, or `be` resolved it differently from the file +this action read. The run stops rather than continue on a CLI nobody chose. + +--- + ### `BE_NPM_AUTH` appears to do nothing This is expected unless: diff --git a/cli/action.yml b/cli/action.yml index 6cb7e5b..42f5680 100644 --- a/cli/action.yml +++ b/cli/action.yml @@ -1,10 +1,14 @@ name: "Setup beplus CLI" -description: "Install and verify beplus CLI + authenticate npm for beplus.cloud packages" +description: "Install and verify the beplus CLI the repository pins (beplus.estate.json → cli.version) + authenticate npm for beplus.cloud packages" inputs: BE_CLI_VERSION: - description: "Version of beplus CLI to install (default: latest)" + description: >- + Version of beplus CLI to install, passed to `be` (e.g. 2.11.0, 2, latest). + Empty (the default) installs the version the repository pins in the nearest + beplus.estate.json → cli.version, via `be auto`; a repository without a pin + gets `latest`, with a warning that it floats. A value here wins over the pin. required: false - default: "latest" + default: "" BE_NPM_AUTH: description: "Authenticate beplus.cloud NPM? (requires BE_NPM_TARGET and BE_NPM_TOKEN env vars)" required: false @@ -12,6 +16,13 @@ inputs: BE_PREFIX: description: "Install prefix for beplus binaries (must be writable)." required: false + BE_WORKING_DIRECTORY: + description: >- + Where the pin is looked up: the nearest beplus.estate.json at or above this + directory, exactly as `be auto` finds it. Relative to the workspace. + Default: the workspace (check the repository out first). + required: false + default: "" runs: using: "composite" @@ -32,18 +43,113 @@ runs: echo "BE_PREFIX=$BE_PREFIX" >> "$GITHUB_ENV" echo "$BE_PREFIX/bin" >> "$GITHUB_PATH" + # Which CLI this run gets. A CLI release must never change what a repository's + # CI does without a commit in that repository, so the version comes from the + # pin the repository commits — not from whatever was released this morning. + # + # This step only decides WHETHER there is a pin. Resolving and validating it + # is `be auto`'s job (one implementation, the same one a laptop and a Docker + # build run); the pin read here is kept to prove, after the install, that + # `be auto` installed exactly it. + - name: Choose the beplus CLI version + id: choose + shell: bash + working-directory: ${{ inputs.BE_WORKING_DIRECTORY || github.workspace }} + env: + INPUT_VERSION: ${{ inputs.BE_CLI_VERSION }} + run: | + set -euo pipefail + + # The nearest beplus.estate.json at or above this directory — the same + # walk `be auto` does. + manifest="" + dir="$PWD" + while :; do + if [[ -e "$dir/beplus.estate.json" ]]; then + manifest="$dir/beplus.estate.json" + break + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + # Annotations point at the file relative to the repository when they can. + file="${manifest#"$GITHUB_WORKSPACE"/}" + + pin="" + if [[ -n "$manifest" ]]; then + command -v jq > /dev/null || { echo "::error::jq is required to read the beplus CLI pin from $manifest."; exit 1; } + # A manifest that cannot be read is not "no pin": fail, never float. + if ! pin="$(jq -r '.cli.version // empty' "$manifest")"; then + echo "::error file=$file::Cannot read cli.version from $manifest (invalid JSON, or \"cli\" is not an object)." + exit 1 + fi + if [[ "$pin" =~ [[:space:]] ]]; then + echo "::error file=$file::cli.version in $manifest is not a version: \"$pin\"." + exit 1 + fi + fi + + version="$INPUT_VERSION" + floating="" + if [[ -n "$INPUT_VERSION" ]]; then + if [[ -n "$pin" && "${INPUT_VERSION#v}" != "${pin#v}" ]]; then + echo "::warning file=$file::BE_CLI_VERSION=$INPUT_VERSION overrides the beplus CLI $pin pinned in $manifest." + fi + pin="" + echo "Installing beplus CLI $INPUT_VERSION (BE_CLI_VERSION)." + elif [[ -n "$pin" ]]; then + version="auto" + echo "Installing beplus CLI $pin, pinned in $manifest (be auto)." + else + # Today's behaviour, said out loud. + version="latest" + floating="${manifest:-no beplus.estate.json at or above $PWD}" + echo "No beplus CLI pin ($floating): installing latest." + fi + + { + echo "version=$version" + echo "pin=$pin" + echo "manifest=$manifest" + echo "floating=$floating" + } >> "$GITHUB_OUTPUT" + - name: Install beplus CLI shell: bash + working-directory: ${{ inputs.BE_WORKING_DIRECTORY || github.workspace }} + env: + VERSION: ${{ steps.choose.outputs.version }} run: | set -euo pipefail - be ${{ inputs.BE_CLI_VERSION }} + be "$VERSION" - name: Verify beplus CLI version shell: bash + env: + PIN: ${{ steps.choose.outputs.pin }} + MANIFEST: ${{ steps.choose.outputs.manifest }} + FLOATING: ${{ steps.choose.outputs.floating }} run: | set -euo pipefail + out="$(beplus --version)" echo "beplus CLI version:" - beplus --version + echo "$out" + + installed="" + if [[ "$out" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?) ]]; then + installed="${BASH_REMATCH[1]}" + fi + file="${MANIFEST#"$GITHUB_WORKSPACE"/}" + + # A pinned run installs the pin or fails — never "something close to it". + if [[ -n "$PIN" && "$installed" != "${PIN#v}" ]]; then + echo "::error file=$file::$MANIFEST pins beplus CLI $PIN, but \`be auto\` installed ${installed:-an unknown version}." + exit 1 + fi + + if [[ -n "$FLOATING" ]]; then + echo "::warning${MANIFEST:+ file=$file}::This repository does not pin the beplus CLI ($FLOATING), so its CI floats on \`be latest\` — every CLI release changes it without a commit here. Pin it: add \"cli\": { \"version\": \"${installed:-x.y.z}\" } to beplus.estate.json." + fi - name: beplus npm auth if: ${{ inputs.BE_NPM_AUTH == 'true'}} diff --git a/test/cli/inexact/beplus.estate.json b/test/cli/inexact/beplus.estate.json new file mode 100644 index 0000000..16cec19 --- /dev/null +++ b/test/cli/inexact/beplus.estate.json @@ -0,0 +1,9 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: a pin that is not an exact", + "version. `2` would float across every 2.x release, so it must fail the run,", + "never install the newest 2.x." + ], + "environments": ["dev"], + "cli": { "version": "2" } +} diff --git a/test/cli/pinned/beplus.estate.json b/test/cli/pinned/beplus.estate.json new file mode 100644 index 0000000..acfba51 --- /dev/null +++ b/test/cli/pinned/beplus.estate.json @@ -0,0 +1,9 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: a repository that pins its beplus CLI.", + "2.10.0 on purpose, NOT the newest release: a run that floated on `be latest`", + "instead of honouring the pin cannot pass by accident." + ], + "environments": ["dev"], + "cli": { "version": "2.10.0" } +} diff --git a/test/cli/unpinned/beplus.estate.json b/test/cli/unpinned/beplus.estate.json new file mode 100644 index 0000000..26dc72a --- /dev/null +++ b/test/cli/unpinned/beplus.estate.json @@ -0,0 +1,8 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: an estate manifest with no `cli`", + "section. The action keeps today's behaviour (`be latest`) and warns that the", + "repository floats." + ], + "environments": ["dev"] +} From cd3281038c6029b3033c3353d0573ef6e272f451 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Tue, 6 Oct 2026 11:48:53 +0200 Subject: [PATCH 2/3] feat(infra-diff): the estates' pull-request cdk diff, as one reusable workflow (BE-218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bepluscloud/ai, ironmountain-cz/monorepo and mrkithq/monorepo each carried a 186-line infra-diff.yml. Their jobs differed in three values, and all three are derived now, so the workflow has no inputs: - the role github-actions----cdk-diff, from the calling repository (github.repository_owner + github.event.repository.name), as each estate's GitHubOIDCStack names it; the account is still vars.BE_AWS_ACCOUNT_ID of the environment; - the region, from beplus.estate.json defaultRegion (vars.AWS_REGION still wins); - the sticky-comment marker, from naming.product lower-cased: be / mountaineer / mrkit, exactly the markers the copies used, so the switch edits a PR's existing comment. A plan step reads the last two from the manifest, validates them, and hands the marker to the comment script through env, never pasted into it. Every other step body is byte-identical to the copies, bar the CLI version: `vars.BE_CLI_VERSION || '2'` floated on the newest 2.x; empty now installs the repository's pin through `be auto` (BE-213). It cannot deploy: the composite always runs `mode: diff` as the read-only …-cdk-diff role, both literals, and with no input declared a caller that passes `mode` is refused by GitHub. The trigger paths, the permissions and the concurrency group stay in each repository's ~30-line caller. scripts/verify-callers.mjs gains `infra-diff`: it compares the shared jobs, resolved for an estate, with the file its caller replaced — every job key, every step, every run/script byte — allowing only declared differences (three derivations, the BE-213 pin, the added manifest step/outputs/env), and checks the shared workflow can only diff. The three callers against bepluscloud origin/dev, Mountaineer origin/dev and MRKIT dev: 30 steps, 18 identical, 3 identical once derived, 6 the intended pin, 3 added, 0 divergent. Nine mutations (deploy mode, deploy role, a mode input, an edited or dropped step, a caller passing mode, a lost path, an extra job, the wrong repository) all go red. The R2-1 library mode is unchanged. Co-Authored-By: Igor Lamos --- .github/workflows/infra-diff.yml | 260 ++++++++++++++++ README.md | 37 +++ scripts/verify-callers.mjs | 491 ++++++++++++++++++++++++++----- 3 files changed, 721 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/infra-diff.yml diff --git a/.github/workflows/infra-diff.yml b/.github/workflows/infra-diff.yml new file mode 100644 index 0000000..829e71d --- /dev/null +++ b/.github/workflows/infra-diff.yml @@ -0,0 +1,260 @@ +# What a pull request would do to an estate, before it does it — once. +# -------------------------------------------------------------------- +# bepluscloud/ai, ironmountain-cz/monorepo and mrkithq/monorepo each carried a +# 186-line copy of this file. Their jobs differed in exactly three values, and +# every one of them is already written down somewhere this workflow can read: +# +# the role github-actions----cdk-diff +# → from the calling repository (`github.repository_owner`, +# `github.event.repository.name`), the way each estate's +# packages/aws/main GitHubOIDCStack names it +# the region us-east-1 / eu-central-1 +# → beplus.estate.json `defaultRegion` (`vars.AWS_REGION` +# still wins, as it did) +# the marker +# → beplus.estate.json `naming.product`, lower-cased, so the +# switch edits a pull request's existing comment instead of +# adding a second one +# +# so there are no inputs. Everything else — every step body — is byte-identical +# to the copies it replaces, bar the CLI version: `vars.BE_CLI_VERSION || '2'` +# floated on the newest 2.x; empty now installs the repository's pin +# (beplus.estate.json → cli.version, `be auto`; BE-213), and the variable still +# wins when it is set. `scripts/verify-callers.mjs infra-diff` proves it. +# +# What stays in the repository is what is the repository's: when it runs (the +# paths), the permissions it grants, and the concurrency group. +# +# name: infra diff +# on: +# pull_request: +# paths: ["packages/aws/**", "beplus.estate.json", ".github/workflows/infra-diff.yml", …] +# permissions: +# id-token: write +# contents: read +# pull-requests: write +# concurrency: +# group: infra-diff-${{ github.event.pull_request.number || github.ref }} +# cancel-in-progress: true +# jobs: +# infra-diff: +# uses: beplus/setup-beplus/.github/workflows/infra-diff.yml@v2 +# secrets: inherit +# +# ⚠️ THIS WORKFLOW CANNOT DEPLOY ANYTHING, AND NOT BECAUSE IT PROMISES NOT TO. +# It assumes `…--cdk-diff`, whose only `sts:AssumeRole` grant is the CDK +# `lookup` role — read-only by construction. The `deploy` / `file-publishing` / +# `image-publishing` roles that `cdk deploy` needs are not reachable from it, +# and `…--cdk-deploy`, which can reach them, does not trust +# `pull_request` at all. Both are IAM, in the account, deployed from each +# estate's `packages/aws/main`. +# +# And there is nothing here a caller can turn: no input reaches the composite. +# `mode: diff` and the `-cdk-diff` suffix are literals, and a caller that passes +# `mode` (or anything else) under `with:` is refused by GitHub before a job +# starts, because no input is declared. Keep it that way — an input that could +# select the mode or the role is exactly the switch the role split exists to +# make useless. `beplus infra lint` still checks the caller's own jobs. +# +# See cli_v2 EXTRACTION-PLAN.md, slice R2-3, and Linear BE-218. + +name: infra diff + +on: + workflow_call: + secrets: + BE_NPM_TOKEN: + description: >- + npm.beplus.cloud token for the @beplus scope. An environment secret in + the estates — the diff job claims the environment, which is how it + reaches it — so `secrets: inherit` (or nothing) is all a caller passes. + required: false + +# The caller must grant all three: a called workflow can only narrow these. +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + # The matrix comes from beplus.estate.json so that the + # list of CDK apps lives in one file rather than in three. + plan: + name: plan + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + packages: ${{ steps.plan.outputs.packages }} + region: ${{ steps.estate.outputs.region }} + marker: ${{ steps.estate.outputs.marker }} + steps: + - uses: actions/checkout@v4 + # `beplus infra` reads beplus.estate.json and nothing + # else — no AWS, no network. The install is the price of the five .mjs + # programs this plan job used to carry a copy of; see cli_v2 + # EXTRACTION-PLAN.md, slice R1-2. + - name: Install the beplus CLI + uses: beplus/setup-beplus/cli@v2 + with: + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} + + # ⚠️ THE CHECK THAT NO PULL-REQUEST PATH CAN DEPLOY. It reads the calling + # repository's own workflows against its own manifest, and it belongs on a + # PR — where a reviewer sees it go red — not only in an npm script nobody + # runs. + - name: Check the workflows against the manifest + run: beplus infra lint + + - id: plan + env: + MODE: diff + BASE_REF: ${{ github.base_ref }} + run: beplus infra matrix --githubOutput >> "$GITHUB_OUTPUT" + + # The two values each estate's copy of this file wrote out by hand, read + # from the manifest that already holds them (the CLI resolves the same + # file, in the same order, with the same us-east-1 default). + # + # Both come from the pull request's own tree, so both are treated as + # data: checked here, and handed to the comment script through `env`, + # never pasted into it. + - name: Read the region and the comment marker from the manifest + id: estate + run: | + set -euo pipefail + manifest=beplus.estate.json + [ -f "$manifest" ] || manifest=.github/actions/cdk-package/packages.json + + # ⚠️ NOT us-east-1 EVERYWHERE: Mountaineer runs in eu-central-1, and + # a wrong region addresses an account with nothing in it. + region="$(jq -r '.defaultRegion // "us-east-1"' "$manifest")" + if ! [[ "$region" =~ ^[a-z]{2}(-[a-z]+)+-[0-9]+$ ]]; then + echo "::error file=$manifest::defaultRegion \"$region\" is not an AWS region." + exit 1 + fi + + # be, mountaineer, mrkit — the markers the three copies used. + product="$(jq -r '.naming.product // empty' "$manifest")" + marker="$(printf '%s' "${product:-${GITHUB_REPOSITORY#*/}}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._-' '-')" + + echo "region=$region" >> "$GITHUB_OUTPUT" + echo "marker=$marker" >> "$GITHUB_OUTPUT" + + diff: + name: ${{ matrix.package }} · ${{ matrix.env }} + needs: plan + runs-on: ubuntu-latest + # Claiming the environment is how the job reaches BE_NPM_TOKEN, which is an + # environment secret. On GitHub Free it adds no gate; with Environment + # protection rules it would make every diff wait for a reviewer, which is + # why the reviewers go on `infra.yml`'s jobs and not on these. + environment: ${{ matrix.env }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + # Kept from the estates' copies, where the composite was a LOCAL action + # and the runner could not find its action.yml without a checkout. The + # shared composite checks the repository out itself; this one stays so + # the steps are exactly the ones the copies ran. + - uses: actions/checkout@v4 + - uses: beplus/setup-beplus/cdk-package@v2 + with: + cli-version: ${{ vars.BE_CLI_VERSION }} + package: ${{ matrix.package }} + env: ${{ matrix.env }} + mode: diff + role-arn: arn:aws:iam::${{ vars.BE_AWS_ACCOUNT_ID }}:role/github-actions-${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ matrix.env }}-cdk-diff + aws-region: ${{ vars.AWS_REGION || needs.plan.outputs.region }} + npm-token: ${{ secrets.BE_NPM_TOKEN }} + npm-target: ${{ vars.BE_NPM_TARGET }} + + # One comment per PACKAGE, not per matrix leg: `main` deploys to every + # account, and one comment per account about one package is how a reviewer + # learns to scroll past all of them. + comment: + name: comment + needs: [plan, diff] + if: always() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: diffs + pattern: cdk-diff-* + merge-multiple: false + + - uses: actions/github-script@v7 + env: + PACKAGES: ${{ needs.plan.outputs.packages }} + DIFF_JOB_RESULT: ${{ needs.diff.result }} + MARKER: ${{ needs.plan.outputs.marker }} + with: + script: | + const { readFileSync, readdirSync, existsSync } = require("node:fs"); + const { join } = require("node:path"); + + // Sticky by an HTML marker rather than by a bot name: the same + // comment is edited on every push, so a 40-commit PR has six + // comments and not two hundred and forty. + const marker = (pkg) => ``; + const MAX = 55000; // GitHub rejects a comment body over 65536. + + const packages = JSON.parse(process.env.PACKAGES || "[]"); + const root = "diffs"; + const existing = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: context.issue.number, per_page: 100, + }); + + for (const pkg of packages) { + // One artifact per (package, environment): cdk-diff--. + const dirs = existsSync(root) + ? readdirSync(root).filter((d) => d.startsWith(`cdk-diff-${pkg}-`)) + : []; + + const sections = []; + for (const dir of dirs.sort()) { + const env = dir.replace(`cdk-diff-${pkg}-`, ""); + const files = readdirSync(join(root, dir)); + const body = files + .filter((f) => f.endsWith(".diff.txt")) + .map((f) => readFileSync(join(root, dir, f), "utf8")) + .join("\n") || "_no output captured_"; + const nag = files.find((f) => f.startsWith("cdk-nag-")); + sections.push( + `` + + `${env}\n\n\`\`\`diff\n${body.trim()}\n\`\`\`\n` + + (nag ? `\n**cdk-nag**\n\n${readFileSync(join(root, dir, nag), "utf8")}\n` : "") + + ``, + ); + } + + let text = [ + marker(pkg), + `### \`packages/aws/${pkg}\` — cdk diff`, + "", + sections.length ? sections.join("\n\n") : "_no diff was produced for this package_", + "", + `${process.env.DIFF_JOB_RESULT === "success" + ? "All diff jobs succeeded." + : `⚠️ The diff matrix finished \`${process.env.DIFF_JOB_RESULT}\` — a section may be missing rather than empty.` + } Full output is in the run's artifacts.`, + ].join("\n"); + + if (text.length > MAX) { + text = text.slice(0, MAX) + + "\n```\n\nTruncated. The whole diff is in this run's artifacts."; + } + + const previous = existing.find((c) => c.body?.includes(marker(pkg))); + if (previous) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body: text }); + } else { + await github.rest.issues.createComment({ + ...context.repo, issue_number: context.issue.number, body: text, + }); + } + } diff --git a/README.md b/README.md index bb23cc7..abbba20 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,43 @@ and stage and prod publish those same bytes — never a build of their own: They install nothing, so they need no `BE_NPM_TOKEN`. +An estate's pull-request `cdk diff` — one sticky comment per CDK app — is one more. +The three estates carried a 186-line copy each; the role, the region and the +comment marker were the only differences, and all three are derived (the role +`github-actions----cdk-diff` from the calling repository, the +region from `beplus.estate.json` → `defaultRegion`, the marker from `naming.product`), +so it has no inputs. The trigger, the permissions and the concurrency group stay +in the repository: + +```yaml +# .github/workflows/infra-diff.yml +name: infra diff +on: + pull_request: + paths: + - "packages/aws/**" + - "packages/apps//.bepluscloud/**" + - ".github/workflows/infra-diff.yml" + - "beplus.estate.json" +permissions: + id-token: write + contents: read + pull-requests: write +concurrency: + group: infra-diff-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + infra-diff: + uses: beplus/setup-beplus/.github/workflows/infra-diff.yml@v2 + secrets: inherit +``` + +It cannot deploy: the composite always runs in `mode: diff` as the read-only +`…-cdk-diff` role, and nothing a caller passes can change either. It installs the +CLI the repository pins (`cli.version`). `node scripts/verify-callers.mjs infra-diff +--repo ` proves a caller plus this workflow reproduce the file it replaced, +step for step. + A repository's docs site — `@beplus/docs-site` from beplus/docs — is built and published to GitHub Pages by one more: diff --git a/scripts/verify-callers.mjs b/scripts/verify-callers.mjs index b7e2921..4e5a174 100755 --- a/scripts/verify-callers.mjs +++ b/scripts/verify-callers.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * The R2-1 verification gate. - * --------------------------- + * The caller verification gate (R2-1, R2-3). + * ------------------------------------------ * A reusable workflow cannot be run locally, so this proves the next best thing: * that resolving the shared workflow WITH EACH CALLER'S INPUTS yields the same * enabled steps, in the same order, as the file that caller replaced. @@ -9,88 +9,445 @@ * The originals come from git — `HEAD~1` by default, or $BEFORE_REF — so this * keeps working as a regression check across the commit that matters. * - * Usage: node scripts/verify-callers.mjs - * e.g. node scripts/verify-callers.mjs ~/Git/github.com/beplus + * Usage: + * + * node scripts/verify-callers.mjs + * The thirteen library repositories' ci.yml / publish.yml callers of + * library-ci.yml / library-publish.yml (R2-1). + * e.g. node scripts/verify-callers.mjs ~/Git/github.com/beplus + * + * node scripts/verify-callers.mjs infra-diff --repo [--ref ] + * [--caller ] [--repository ] [--repo …] + * Each estate's `.github/workflows/infra-diff.yml` caller of infra-diff.yml + * (R2-3, BE-218). `--ref` is the commit whose infra-diff.yml the caller + * replaced (default $BEFORE_REF, else HEAD~1). `--caller` checks a caller + * that is not committed yet; the manifest is then read at `--ref` too, + * otherwise from the checkout. `--repository` defaults to the `origin` remote. + * e.g. node scripts/verify-callers.mjs infra-diff \ + * --repo ~/Git/github.com/bepluscloud/ai --ref origin/dev --caller /tmp/bepluscloud.yml * * What it CANNOT prove: that the shared workflow runs green on GitHub. That * needs setup-beplus pushed and tagged, and one repository taken through a real - * publish. Do that with beplus/docs before the other eleven. + * run. For the libraries do that with beplus/docs before the other eleven; for + * infra-diff, with one real pull request per estate, bepluscloud first. */ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; -const ORG = process.argv[2]; -if (!ORG) { +const HERE = new URL('..', import.meta.url).pathname; + +const usage = () => { console.error('usage: node scripts/verify-callers.mjs '); + console.error(' node scripts/verify-callers.mjs infra-diff --repo [--ref ] [--caller ] [--repository ] [--repo …]'); process.exit(2); -} +}; // `yaml` is not a dependency of this repo; borrow the one Rush already installed // in whichever estate is checked out beside it. -const require_ = createRequire(import.meta.url); -let YAML; -for (const candidate of [ - join(ORG, '..', 'mrkithq', 'monorepo', 'packages', 'aws', 'main', 'node_modules', 'yaml'), - join(ORG, '..', 'bepluscloud', 'ai', 'packages', 'aws', 'main', 'node_modules', 'yaml'), -]) { - try { YAML = require_(candidate); break; } catch { /* try the next */ } -} -if (!YAML) { console.error('no `yaml` module found next to this checkout'); process.exit(2); } - -const REPOS = ['ai', 'analytics', 'api', 'auth', 'cdk', 'cms', 'data', 'docs', 'mobile', 'saas', 'server', 'web']; -const HERE = new URL('..', import.meta.url).pathname; +const loadYaml = (roots) => { + const require_ = createRequire(import.meta.url); + for (const root of roots) { + try { return require_(join(root, 'packages', 'aws', 'main', 'node_modules', 'yaml')); } catch { /* try the next */ } + } + console.error('no `yaml` module found next to this checkout'); + process.exit(2); +}; + +const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', maxBuffer: 1e8 }); + +/** JSON with sorted keys: two parsed YAML values are equal iff this is. */ +const canon = (value) => JSON.stringify(value, (_key, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) + : v); + +// ═════════════════════════════════════════════════════════════════════════════ +// R2-1 — the library repositories +// ═════════════════════════════════════════════════════════════════════════════ + +const libraries = (ORG) => { + const YAML = loadYaml([ + join(ORG, '..', 'mrkithq', 'monorepo'), + join(ORG, '..', 'bepluscloud', 'ai'), + ]); + + const REPOS = ['ai', 'analytics', 'api', 'auth', 'cdk', 'cms', 'data', 'docs', 'mobile', 'saas', 'server', 'web']; + + const shared = Object.fromEntries( + ['library-ci', 'library-publish'].map((n) => [ + n, YAML.parse(readFileSync(join(HERE, '.github', 'workflows', `${n}.yml`), 'utf8')), + ]), + ); + + /** + * The steps a caller's inputs actually enable, in order. + * + * ⚠️ EVERY `inputs.x` IN THE CONDITION COUNTS, not just the first. The gates are + * compound — `inputs.force && inputs.force-change-files` — and reading only the + * leading one reported six repositories as gaining a step that is in fact + * skipped. A step is enabled unless SOME input it names is literally `false`; + * anything else leaves it in, with its own runtime condition intact. + */ + const enabledSteps = (workflow, withInputs) => { + const job = Object.values(workflow.jobs)[0]; + return job.steps + .filter((step) => { + const named = [...String(step.if ?? '').matchAll(/inputs\.([a-z-]+)/g)].map((m) => m[1]); + return !named.some((name) => withInputs[name] === false); + }) + .map((step) => step.name ?? `uses:${step.uses}`); + }; + + let failures = 0; + for (const repo of REPOS) { + for (const [file, workflow] of [['ci', 'library-ci'], ['publish', 'library-publish']]) { + const path = `.github/workflows/${file}.yml`; + const before = YAML.parse(git(join(ORG, repo), 'show', `${process.env.BEFORE_REF ?? 'HEAD~1'}:${path}`)); + const caller = YAML.parse(readFileSync(join(ORG, repo, path), 'utf8')); -const shared = Object.fromEntries( - ['library-ci', 'library-publish'].map((n) => [ - n, YAML.parse(readFileSync(join(HERE, '.github', 'workflows', `${n}.yml`), 'utf8')), - ]), -); + const wanted = Object.values(before.jobs)[0].steps.map((s) => s.name ?? `uses:${s.uses}`); + const got = enabledSteps(shared[workflow], Object.values(caller.jobs)[0].with ?? {}); + + if (JSON.stringify(wanted) === JSON.stringify(got)) { + console.log(` ✓ ${repo}/${file} ${got.length} steps`); + } else { + failures += 1; + console.log(` ✗ ${repo}/${file}`); + console.log(` had: ${wanted.join(' | ')}`); + console.log(` gets: ${got.join(' | ')}`); + } + } + } + console.log(); + console.log(failures === 0 + ? ` all ${REPOS.length * 2} workflows resolve to the steps they replace` + : ` ${failures} workflow(s) would not do what they did before`); + process.exit(failures === 0 ? 0 : 1); +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// R2-3 — infra-diff.yml in the three estates (BE-218) +// ═════════════════════════════════════════════════════════════════════════════ +// +// Stricter than the library check, because there are no inputs to resolve: the +// shared jobs are compared to the replaced file's jobs WHOLE — every job key, +// every step, every `run` / `script` byte — and the only differences allowed +// are the ones declared below, each with its reason. Anything else is divergent. + +const WORKFLOW = join('.github', 'workflows', 'infra-diff.yml'); +const SHARED_CALL = /^beplus\/setup-beplus\/\.github\/workflows\/infra-diff\.yml@\S+$/; /** - * The steps a caller's inputs actually enable, in order. - * - * ⚠️ EVERY `inputs.x` IN THE CONDITION COUNTS, not just the first. The gates are - * compound — `inputs.force && inputs.force-change-files` — and reading only the - * leading one reported six repositories as gaining a step that is in fact - * skipped. A step is enabled unless SOME input it names is literally `false`; - * anything else leaves it in, with its own runtime condition intact. + * What the shared workflow DERIVES where each copy wrote a literal. Applied to + * the shared side with the estate's own values; afterwards the text must be the + * copy's, byte for byte. */ -const enabledSteps = (workflow, withInputs) => { - const job = Object.values(workflow.jobs)[0]; - return job.steps - .filter((step) => { - const named = [...String(step.if ?? '').matchAll(/inputs\.([a-z-]+)/g)].map((m) => m[1]); - return !named.some((name) => withInputs[name] === false); - }) - .map((step) => step.name ?? `uses:${step.uses}`); +const DERIVED = [ + { + what: 'role ARN: - from the calling repository', + shared: '${{ github.repository_owner }}-${{ github.event.repository.name }}', + original: (e) => `${e.owner}-${e.name}`, + }, + { + what: 'region default: beplus.estate.json defaultRegion', + shared: '${{ vars.AWS_REGION || needs.plan.outputs.region }}', + original: (e) => `\${{ vars.AWS_REGION || '${e.region}' }}`, + }, + { + what: 'comment marker: beplus.estate.json naming.product, lower-cased', + shared: ' (${manifest.path})`); + + // ── The caller: what stays in the repository, exactly as it was ────────── + const KEPT = ['name', 'on', 'permissions', 'concurrency']; + for (const key of Object.keys(caller)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`caller: top-level \`${key}\` — nothing but ${KEPT.join(', ')} and the call belongs here`); } + for (const key of Object.keys(original)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`original: top-level \`${key}\` has no home in caller or shared workflow`); + } + for (const key of KEPT) { + if (canon(caller[key]) !== canon(original[key])) { + problems.push(`caller: \`${key}\` differs from the original\n had: ${canon(original[key])}\n gets: ${canon(caller[key])}`); + } + } + // The jobs run with what the original granted; a called workflow can only narrow it. + if (canon(shared.permissions) !== canon(original.permissions)) { + problems.push(`shared: permissions ${canon(shared.permissions)} ≠ the original's ${canon(original.permissions)}`); + } + const callerJobs = Object.entries(caller.jobs ?? {}); + if (callerJobs.length !== 1) problems.push(`caller: ${callerJobs.length} jobs — exactly one, the call, belongs here`); + for (const [jobName, job] of callerJobs) { + if (!SHARED_CALL.test(String(job.uses ?? ''))) problems.push(`caller: job "${jobName}" does not call beplus/setup-beplus/.github/workflows/infra-diff.yml`); + for (const key of Object.keys(job.with ?? {})) { + if (!declaredInputs.includes(key)) problems.push(`caller: job "${jobName}" passes \`${key}\`, which the shared workflow does not declare — GitHub refuses the run`); + } + if (job.secrets !== 'inherit' && job.secrets !== undefined) problems.push(`caller: job "${jobName}" passes secrets explicitly — use \`secrets: inherit\``); + for (const key of Object.keys(job)) { + if (!['uses', 'with', 'secrets', 'name'].includes(key)) problems.push(`caller: job "${jobName}" sets \`${key}\` — the shared jobs own it`); + } + } + if (problems.length === 0) { + console.log(` ✓ caller ${KEPT.join(', ')} identical to the original; one job, ${callerJobs[0][1].uses}, secrets: ${callerJobs[0][1].secrets}`); + } + + // ── The jobs: the shared workflow, resolved for this estate, vs the copy ─ + const derivedPairs = DERIVED.map((d) => [d.shared, d.original(estate)]); + const intendedPairs = INTENDED.map((d) => [d.original, d.shared]); + const derivedHits = DERIVED.map(() => 0); + const intendedHits = INTENDED.map(() => 0); + + const sharedJobs = Object.keys(shared.jobs ?? {}); + const originalJobs = Object.keys(original.jobs ?? {}); + if (canon(sharedJobs) !== canon(originalJobs)) { + problems.push(`jobs: the shared workflow has [${sharedJobs}], the original had [${originalJobs}]`); + } + + for (const jobName of originalJobs.filter((j) => sharedJobs.includes(j))) { + const added = ADDED[jobName] ?? {}; + const { steps: sharedSteps = [], ...sharedJob } = shared.jobs[jobName]; + const { steps: originalSteps = [], ...originalJob } = original.jobs[jobName]; + + // Job keys. + const resolvedJob = substitute(sharedJob, derivedPairs, derivedHits); + for (const output of added.outputs ?? []) { + if (resolvedJob.outputs?.[output] === undefined) problems.push(`job "${jobName}": declared added output "${output}" is gone`); + else delete resolvedJob.outputs[output]; + } + if (canon(resolvedJob) !== canon(substitute(originalJob, intendedPairs, intendedHits))) { + problems.push(`job "${jobName}": job keys differ\n had: ${canon(originalJob)}\n gets: ${canon(resolvedJob)}`); + } + const extra = (added.outputs ?? []).length ? ` (+ outputs ${added.outputs.join(', ')})` : ''; + console.log(` job ${jobName}${extra}`); + + // Steps, in order. A declared added step is reported where it sits and + // skipped; every other shared step is paired with the original's next one. + const isAdded = (s) => (added.steps ?? []).includes(s.id); + for (const key of added.steps ?? []) { + if (!sharedSteps.some((s) => s.id === key)) problems.push(`job "${jobName}": declared added step "${key}" is gone`); + } + const pairs = []; + let next = 0; + for (const step of sharedSteps) { + if (isAdded(step)) pairs.push([step, null, true]); + else pairs.push([step, originalSteps[next++], false]); + } + for (; next < originalSteps.length; next += 1) pairs.push([undefined, originalSteps[next], false]); + for (const [i, [mine, theirs, isNew]] of pairs.entries()) { + const label = stepKey(mine ?? theirs).padEnd(58); + if (isNew) { + counts.added += 1; + console.log(` + ${label} added — reads the derived values from the manifest`); + continue; + } + if (!mine || !theirs) { + counts.divergent += 1; + problems.push(`job "${jobName}" step ${i + 1}: ${mine ? 'only in the shared workflow' : 'missing from the shared workflow'} — ${stepKey(mine ?? theirs)}`); + console.log(` ✗ ${label} ${mine ? 'only in the shared workflow' : 'MISSING from the shared workflow'}`); + continue; + } + if (canon(mine) === canon(theirs)) { + counts.identical += 1; + console.log(` ✓ ${label} identical`); + continue; + } + const dHits = DERIVED.map(() => 0); + const iHits = INTENDED.map(() => 0); + const resolved = substitute(mine, derivedPairs, dHits); + for (const envKey of added.stepEnv?.[stepKey(mine)] ?? []) { + if (resolved.env?.[envKey] === undefined) problems.push(`job "${jobName}": declared added env ${envKey} is gone`); + else delete resolved.env[envKey]; + } + const target = substitute(theirs, intendedPairs, iHits); + dHits.forEach((n, j) => { derivedHits[j] += n; }); + iHits.forEach((n, j) => { intendedHits[j] += n; }); + if (canon(resolved) === canon(target)) { + const why = [ + ...DERIVED.filter((_, j) => dHits[j]).map((d) => d.what), + ...INTENDED.filter((_, j) => iHits[j]).map((d) => `intended: ${d.what}`), + ]; + if (iHits.some(Boolean)) counts.intended += 1; else counts.derived += 1; + console.log(` ${iHits.some(Boolean) ? '~' : '✓'} ${label} ${why.join('; ')}`); + } else { + counts.divergent += 1; + problems.push(`job "${jobName}" step ${i + 1} "${stepKey(mine)}" differs\n had: ${canon(target)}\n gets: ${canon(resolved)}`); + console.log(` ✗ ${label} DIVERGENT`); + } + } + } + + // A declared difference that never applied means the shared workflow moved + // and this list did not — the proof would be checking a file that no longer exists. + DERIVED.forEach((d, j) => { if (!derivedHits[j]) problems.push(`declared derivation never applied: ${d.what}`); }); + + for (const problem of problems) console.log(` ✗ ${problem}`); + const steps = counts.identical + counts.derived + counts.intended + counts.added + counts.divergent; + console.log(` ${steps} steps: ${counts.identical} identical, ${counts.derived} identical once derived, ` + + `${counts.intended} intended (BE-213 pin), ${counts.added} added, ${counts.divergent} divergent` + + `${problems.length ? ` — ${problems.length} problem(s)` : ''}`); + divergent += problems.length; + for (const k of Object.keys(totals)) totals[k] += counts[k]; } -} -console.log(); -console.log(failures === 0 - ? ` all ${REPOS.length * 2} workflows resolve to the steps they replace` - : ` ${failures} workflow(s) would not do what they did before`); -process.exit(failures === 0 ? 0 : 1); + + console.log(); + console.log(` ${entries.length} estate(s): ${totals.identical} identical, ${totals.derived} identical once derived, ` + + `${totals.intended} intended, ${totals.added} added, ${totals.divergent} divergent step(s)`); + console.log(divergent === 0 + ? ` every caller + infra-diff.yml reproduces the workflow it replaces` + : ` ${divergent} problem(s): a caller would not do what its workflow did before`); + process.exit(divergent === 0 ? 0 : 1); +}; + +// ───────────────────────────────────────────────────────────────────────────── +const [first, ...rest] = process.argv.slice(2); +if (!first) usage(); +if (first === 'infra-diff') infraDiff(rest); +else if (existsSync(first)) libraries(first); +else usage(); From b07d9e08a25bba252744b7e1118e1181100c61c3 Mon Sep 17 00:00:00 2001 From: "beplus-agent-claude[bot]" <300068699+beplus-agent-claude[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:59:35 +0100 Subject: [PATCH 3/3] =?UTF-8?q?feat(db):=20the=20estates'=20database=20gat?= =?UTF-8?q?es=20=E2=80=94=20the=20migrations=20PR=20check=20and=20the=20ni?= =?UTF-8?q?ghtly=20drift=20=E2=80=94=20as=20two=20reusable=20workflows=20(?= =?UTF-8?q?BE-232)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mrkithq/monorepo and ironmountain-cz/monorepo each carried db-migrations-pr.yml (146 / 137 lines) and db-drift-env.yml (105 / 91); bepluscloud/ai had neither. Both now live here once and run the repository's own `beplus db` commands — @beplus/database-tools, resolved from the repository's install, never bundled in the CLI (D3) — over beplus.estate.json: - db-migrations-pr.yml: a pgvector service pinned to pgvector/pgvector:0.8.7-pg16 (the copies floated on pg16), the pinned CLI, `beplus npm auth` with the stub-200 registry check, rush install / build --to the database project, then `beplus db check`, `db check pending`, `db check chains`, `db check rebuild --compare` — each the command the copies' `npm run db:check:*` ran or now resolves to. BE_POSTGRES_URL is its own localhost service; BE_NPM_TOKEN is its only secret. - db-drift-env.yml: `beplus db diff migrations ` for each environment, then each environment against the next, aggregated so one drift does not hide another; scheduled + on demand stay in the caller. Connection strings stay secrets (BE_POSTGRES_URL_DEV / _STAGE / _PROD): no account has a database-read OIDC role yet, and `db diff` does not reach a private instance through the tunnel, so it assumes no AWS role — said in the header, with what it takes. A plan job derives what each copy wrote out: the rush project (database.package → its package.json name; `project` wins), the test database (naming.prefix + _test: mrkit_test, mcl_test, be_test), the environments (`environments`; the input wins — MRKIT passes `dev prod` until `db diff` knows stage). All validated before a step pastes them. `github-environment` says which GitHub environment holds the secrets when they are environment secrets (MRKIT: dev); empty claims none. The dump image is no longer set in the job: the repository's database.dumpImage, else the tools' pin — pgvector/pgvector:pg16, the value the copies set. The registry check is the `cli` action's, opt-in: BE_NPM_CHECK names package directories whose @beplus dependencies must come back as real packages after the auth. npm.beplus.cloud answers an unauthenticated read with HTTP 200 and {"status":200,"message":"OK"}; `npm view` prints nothing and exits 0, and pnpm fails minutes later with ERR_PNPM_TARBALL_INTEGRITY. The check fails the step first, naming the stub or npm's error (a 404). Locally against npm.beplus.cloud: with the token every dependency of the three estates' P3 database packages resolves but @beplus/database (unpublished → 404, as it must until 0.1.0 is out); anonymous → the stub, red. test-cli.yml gains the anonymous leg. scripts/verify-callers.mjs gains `db`: the shared jobs, resolved for an estate, against the copy's jobs — every key, every step — with the derivation not re-implemented but RUN (the plan step, on the estate's own manifest and package.json), and the drift job's guard and comparison, which are loops over the environments now, run side by side with the copies' (stub npm / beplus, every scenario that changes their output: 6 for the guard, 5 for the comparison) — same stdout, stderr, exit. Callers for MRKIT (dev), Mountaineer (origin/dev) and bepluscloud (origin/dev, gains both, with the database section BE-234 adds): 0 divergent — 12 identical, 6 identical once derived, 11 intended (CLI pin, the auth step's name in Mountaineer, `npm run db:check:*` → `beplus db check …`), 4 run side by side, 1 added (MRKIT's chains check, which its CI never ran), 1 removed (MRKIT's drift build: `db diff` reads no compiled schema). Eighteen mutations (an edited or dropped step, a remote BE_POSTGRES_URL, a connection secret in the PR gate, a migrate in the drift job, id-token, a wrong test database, a floating service image, a caller that drops a trigger, passes an undeclared input, renames its job, forgets its environment or lets stage in) all go red. infra-diff mode unchanged (30 steps, 0 divergent). The library mode no longer crashes: HEAD~1 compared each caller with itself once the callers had landed; it now takes the parent of the commit that made each file a caller ($BEFORE_REF still wins). ci: 12/12 identical; publish: 12 report library-publish's deliberate later changes (#8–#10). Co-authored-by: bea-claude Co-authored-by: Igor Lamos --- .github/workflows/db-drift-env.yml | 321 ++++++++++++ .github/workflows/db-migrations-pr.yml | 273 +++++++++++ .github/workflows/test-cli.yml | 20 +- README.md | 77 +++ cli/README.md | 36 ++ cli/action.yml | 79 +++ scripts/verify-callers.mjs | 650 ++++++++++++++++++++++++- test/cli/npm-check/package.json | 10 + 8 files changed, 1454 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/db-drift-env.yml create mode 100644 .github/workflows/db-migrations-pr.yml create mode 100644 test/cli/npm-check/package.json diff --git a/.github/workflows/db-drift-env.yml b/.github/workflows/db-drift-env.yml new file mode 100644 index 0000000..9ea8edd --- /dev/null +++ b/.github/workflows/db-drift-env.yml @@ -0,0 +1,321 @@ +# Do the live databases still match the migrations? — once. +# --------------------------------------------------------- +# ironmountain-cz/monorepo (91 lines) and mrkithq/monorepo (105) each carried a +# copy of this file; bepluscloud/ai had none. It verifies that each environment's +# database structurally matches the committed migrations (drizzle/schema.sql) and +# the next environment in the promotion order. SEPARATE from the pull-request +# gate (db-migrations-pr.yml) because it needs connection secrets — it never +# runs on a pull request. +# +# The comparison is the repository's own `beplus db diff` (@beplus/database-tools, +# resolved from the repository's install), where the copies ran `npm run db:diff` +# over a `bin/db-diff.ts` copy of their own; it prints what that printed, line +# for line (BE-231's golden harness is the proof). What the copies wrote out by +# hand comes from beplus.estate.json: +# +# the rush project → `database.package` (default packages/modules/database) +# and its package.json `name`; the `project` input wins +# the environments dev, prod (each copy's list) +# → beplus.estate.json `environments`; the `environments` +# input wins (MRKIT passes `dev prod` until its stage +# database exists) +# +# and the guard and the drift step loop over that list. For the copies' two +# environments they print exactly what the copies printed and exit as they did — +# `scripts/verify-callers.mjs db` runs both side by side to prove it. Every other +# step body is byte-identical, bar the CLI version — the copies installed +# `latest`; empty now installs the repository's pin (beplus.estate.json → +# cli.version, `be auto`; BE-213), and `vars.BE_CLI_VERSION` still wins when it +# is set — and the registry check after `beplus npm auth` (`BE_NPM_CHECK`). The +# copy's `rush build` is gone: `beplus db diff` builds nothing it reads. +# +# Connection strings (read-capable): BE_POSTGRES_URL_DEV, BE_POSTGRES_URL_STAGE, +# BE_POSTGRES_URL_PROD — secrets, as in the copies. Repository secrets, or +# environment secrets of the environment named in `github-environment` (MRKIT's +# live on `dev`, beside BE_NPM_TOKEN). The diff prints only masked URLs; raw +# connection strings are never logged. A missing one fails the guard loudly +# rather than reporting "no drift". +# +# The trigger stays in the repository — the nightly schedule and the manual run: +# +# name: DB Drift (dev/prod) +# on: +# workflow_dispatch: +# schedule: +# - cron: "0 6 * * *" # 06:00 UTC nightly +# permissions: +# contents: read +# concurrency: +# group: db-drift-env-${{ github.workflow }} +# cancel-in-progress: false +# jobs: +# drift: +# uses: beplus/setup-beplus/.github/workflows/db-drift-env.yml@v2 +# secrets: inherit +# +# It only reads: `beplus db diff` dumps the schemas (pg_dump, structure only) and +# compares them. Nothing here migrates, writes or connects with more than the +# secrets' read-capable roles. +# +# NO AWS ROLE, YET. The connection strings are secrets because nothing else +# reaches a database from GitHub today: no account has a database-read OIDC +# role (GitHubOIDCStack trusts cdk-diff, cdk-deploy and provisioning only), and +# `beplus db diff` reads BE_POSTGRES_URL_ — it does not open the ECS-Exec +# tunnel `beplus db migrate --env` uses to reach a private instance. Once both +# exist (the GitHub OIDC construct in @beplus/cdk-estate, and `db diff` through +# the tunnel), this job assumes that read role per environment instead, and the +# secrets go. +# +# See cli_v2 EXTRACTION-PLAN.md, slice R2-4, and Linear BE-232. + +name: DB Drift + +on: + workflow_call: + inputs: + environments: + description: >- + The environments whose databases are compared — with the committed + migrations, and each with the next — space- or comma-separated, in + promotion order (e.g. `dev prod`). Each reads BE_POSTGRES_URL_; dev, + stage and prod are mapped. Default: beplus.estate.json → `environments`. + required: false + type: string + default: "" + project: + description: >- + The Rush project of the database package — what `rush install --to` + names. Default: the package.json `name` of beplus.estate.json → + `database.package` (default packages/modules/database). + required: false + type: string + default: "" + github-environment: + description: >- + The GitHub environment the drift job claims, when the connection + strings and BE_NPM_TOKEN are environment secrets there (MRKIT: `dev`). + Empty (the default): none — they are repository secrets. + required: false + type: string + default: "" + secrets: + BE_NPM_TOKEN: + description: npm.beplus.cloud token for the @beplus scope. + required: false + BE_POSTGRES_URL_DEV: + description: A read-capable connection string for dev's database. + required: false + BE_POSTGRES_URL_STAGE: + description: A read-capable connection string for stage's database. + required: false + BE_POSTGRES_URL_PROD: + description: A read-capable connection string for prod's database. + required: false + +permissions: + contents: read + +jobs: + # What the copies wrote out by hand, read from the manifest that already + # holds it — validated here, because it comes from the repository's tree. + plan: + name: plan + runs-on: ubuntu-latest + outputs: + package: ${{ steps.database.outputs.package }} + project: ${{ steps.database.outputs.project }} + environments: ${{ steps.database.outputs.environments }} + label: ${{ steps.database.outputs.label }} + summary: ${{ steps.database.outputs.summary }} + steps: + - uses: actions/checkout@v4 + + - name: Read the database package and the environments from the manifest + id: database + env: + INPUT_PROJECT: ${{ inputs.project }} + INPUT_ENVIRONMENTS: ${{ inputs.environments }} + run: | + set -euo pipefail + manifest=beplus.estate.json + if [ ! -f "$manifest" ]; then + echo "::error::No beplus.estate.json at the repository root: its \`database\` section says which package the comparison runs in." + exit 1 + fi + + # The same default `beplus db` applies (cli-plugin-database's section). + package="$(jq -r '.database.package // "packages/modules/database"' "$manifest")" + if ! [[ "$package" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*)*$ ]] || [[ "/$package/" == */../* ]]; then + echo "::error file=$manifest::database.package \"$package\" is not a relative directory in this repository." + exit 1 + fi + if [ ! -f "$package/package.json" ]; then + echo "::error file=$manifest::database.package is \"$package\", which has no package.json — set it to the directory of the repository's database package." + exit 1 + fi + + project="${INPUT_PROJECT:-$(jq -r '.name // empty' "$package/package.json")}" + if ! [[ "$project" =~ ^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$ ]]; then + if [ -n "$INPUT_PROJECT" ]; then + echo "::error::the \`project\` input: \"$project\" is not a Rush project name." + else + echo "::error file=$package/package.json::\"$project\" is not a Rush project name." + fi + exit 1 + fi + + if [ -n "$INPUT_ENVIRONMENTS" ]; then + list="$(printf '%s' "$INPUT_ENVIRONMENTS" | tr ',' ' ')" + source="::error::the \`environments\` input:" + else + list="$(jq -r '(.environments // []) | map(tostring) | join(" ")' "$manifest")" + source="::error file=$manifest::environments:" + fi + environments="" + for env in $list; do + # The job maps these three connection strings and no others. + case "$env" in + dev | stage | prod) ;; + *) + echo "$source \"$env\" — the drift job reads BE_POSTGRES_URL_DEV, _STAGE and _PROD, and no connection string for \"$env\"." + exit 1 + ;; + esac + case " $environments " in *" $env "*) continue ;; esac + environments="${environments:+$environments }$env" + done + if [ -z "$environments" ]; then + echo "::error file=$manifest::No environment to compare: pass \`environments\`, or list them in beplus.estate.json → environments." + exit 1 + fi + + # "dev vs prod" and "migrations, dev, and prod are structurally 1:1:1", + # the copies' words, for any number of environments. + read -r -a envs <<< "$environments" + label="${environments// / vs }" + ratio="1" + for env in "${envs[@]}"; do ratio="$ratio:1"; done + if [ "${#envs[@]}" -eq 1 ]; then + summary="migrations and ${envs[0]} are structurally $ratio" + else + summary="migrations" + for env in "${envs[@]:0:${#envs[@]}-1}"; do summary="$summary, $env"; done + summary="$summary, and ${envs[-1]} are structurally $ratio" + fi + + echo "database package $package ($project); environments: $environments" + { + echo "package=$package" + echo "project=$project" + echo "environments=$environments" + echo "label=$label" + echo "summary=$summary" + } >> "$GITHUB_OUTPUT" + + drift: + name: structural drift (migrations vs ${{ needs.plan.outputs.label }}) + needs: plan + runs-on: ubuntu-latest + # Where the connection strings and BE_NPM_TOKEN are environment secrets + # (MRKIT: `dev`), the job claims that environment to see them. Empty: none. + environment: ${{ inputs.github-environment }} + timeout-minutes: 20 + + env: + BE_POSTGRES_URL_DEV: ${{ secrets.BE_POSTGRES_URL_DEV }} + BE_POSTGRES_URL_STAGE: ${{ secrets.BE_POSTGRES_URL_STAGE }} + BE_POSTGRES_URL_PROD: ${{ secrets.BE_POSTGRES_URL_PROD }} + # A pinned pg16 client via docker. WHICH image is the repository's: + # beplus.estate.json → database.dumpImage, else @beplus/database-tools' pin + # (pgvector/pgvector:pg16) — so it is not written here. + BE_PGDUMP_ENGINE: docker + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22.14 + + - name: Cache Rush and pnpm + uses: actions/cache@v4 + with: + path: | + common/temp + key: ${{ runner.os }}-${{ runner.arch }}-rush-${{ hashFiles('common/config/rush/pnpm-lock.yaml', 'rush.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rush- + + # @beplus/* lives on npm.beplus.cloud; see db-migrations-pr.yml for why + # the scope has to be authenticated before install. The same step installs + # the beplus CLI the repository pins (beplus.estate.json → cli.version). + - name: Authenticate the @beplus scope + uses: beplus/setup-beplus/cli@v2 + env: + BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} + BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} + with: + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} + BE_NPM_AUTH: true + # ⚠️ AND PROVE IT WORKED. npm.beplus.cloud answers a read it cannot + # authenticate with HTTP 200 and a stub body, never 401, so a token + # that did not reach this job fails nothing until pnpm, minutes later, + # reports ERR_PNPM_TARBALL_INTEGRITY, then ERR_PNPM_NO_VERSIONS. This + # asks the registry for every @beplus package the database package + # depends on, and fails here, naming the cause. + BE_NPM_CHECK: ${{ needs.plan.outputs.package }} + + # Every environment's connection string, or nothing runs: a missing one is + # a failure, never "no drift". The message names all of them, as the + # copies' did. + - name: Guard — secrets present + id: guard + env: + ENVIRONMENTS: ${{ needs.plan.outputs.environments }} + run: | + missing=0 + names="" + for env in $ENVIRONMENTS; do + variable="BE_POSTGRES_URL_$(printf '%s' "$env" | tr '[:lower:]' '[:upper:]')" + names="${names:+$names / }$variable" + [ -n "${!variable:-}" ] || missing=1 + done + if [ "$missing" -ne 0 ]; then + echo "::error::$names secrets are not set" + exit 1 + fi + + # `beplus db diff` needs the repository's @beplus/database-tools installed — + # and nothing built: it reads the committed schema file and the databases, + # no compiled schema (MRKIT's copy built anyway; Mountaineer's did not). + - name: Rush install + run: node common/scripts/install-run-rush.js install --to ${{ needs.plan.outputs.project }} + + # Run every comparison and aggregate, so one failure doesn't hide the others. + # migrations = committed drizzle/schema.sql (canonical "what migrations produce"); + # then each environment against the next one in the promotion order. + - name: Structural drift (migrations vs ${{ needs.plan.outputs.label }}) + id: drift + working-directory: ${{ needs.plan.outputs.package }} + env: + ENVIRONMENTS: ${{ needs.plan.outputs.environments }} + SUMMARY: ${{ needs.plan.outputs.summary }} + run: | + set +e + rc=0 + for env in $ENVIRONMENTS; do + echo "::group::migrations vs $env"; beplus db diff migrations "$env"; [ $? -ne 0 ] && rc=1; echo "::endgroup::" + done + previous="" + for env in $ENVIRONMENTS; do + if [ -n "$previous" ]; then + echo "::group::$previous vs $env"; beplus db diff "$previous" "$env"; [ $? -ne 0 ] && rc=1; echo "::endgroup::" + fi + previous="$env" + done + if [ $rc -ne 0 ]; then + echo "::error::Structural drift detected — see the grouped diffs above" + else + echo "✓ $SUMMARY" + fi + exit $rc diff --git a/.github/workflows/db-migrations-pr.yml b/.github/workflows/db-migrations-pr.yml new file mode 100644 index 0000000..410d674 --- /dev/null +++ b/.github/workflows/db-migrations-pr.yml @@ -0,0 +1,273 @@ +# The pull-request gate for a database's migrations — once. +# --------------------------------------------------------- +# mrkithq/monorepo (146 lines) and ironmountain-cz/monorepo (137) each carried +# a copy of this file; bepluscloud/ai had none. It catches the classes of +# breakage a migrations-only, never-push database is exposed to: +# 1. migration metadata corruption / out-of-order `when` (the silent-skip hazard) +# 2. a TypeScript schema change committed WITHOUT its migration +# 3. an embedded chain (@beplus/ai-db's beplus_ai, @beplus/analytics-db's +# analytics) that no longer resolves, or whose journal is unsound +# 4. a clean database that can no longer be rebuilt from the committed +# migrations, or whose structure no longer matches drizzle/schema.sql +# +# The checks are the repository's own `beplus db` commands (@beplus/database-tools, +# resolved from the repository's install, never bundled in the CLI), where the +# copies ran `npm run db:check:*` scripts over `bin/db-*.ts` copies of their own. +# They print what those scripts printed, line for line (@beplus/database-tools +# holds that parity; BE-231's golden harness proves it), and they read what used +# to be written out per repository from beplus.estate.json: +# +# the rush project @mrkit/database / @mountaineer/database / @bepluscloud/database +# → `database.package` (default packages/modules/database) +# and its package.json `name`; the `project` input wins +# the test database mrkit_test / mcl_test / be_test +# → `naming.prefix`, lower-cased, + `_test` +# the chains → the product's own migrator declaration (`db check chains`) +# +# Every other step body is byte-identical to the copies, bar three deliberate +# changes: the CLI version — `vars.BE_CLI_VERSION || '2'` floated on the newest +# 2.x; empty now installs the repository's pin (beplus.estate.json → +# cli.version, `be auto`; BE-213), and the variable still wins when it is set — +# the registry check after `beplus npm auth` (`BE_NPM_CHECK`), and the Postgres +# service pinned to pgvector 0.8.7 / pg16 instead of the floating `pg16`. The pin +# must be a CLI that bundles @beplus/cli-plugin-database with +# `db check pending | rebuild | chains`. `scripts/verify-callers.mjs db` proves +# all of it, step for step. +# +# What stays in the repository is what is the repository's: when it runs (the +# paths), the permissions it grants, the concurrency group — and, when its +# BE_NPM_TOKEN is an environment secret, which environment holds it: +# +# name: DB Migrations (PR-safe) +# on: +# pull_request: +# paths: ["packages/modules/database/drizzle/**", …, ".github/workflows/db-migrations-pr.yml"] +# permissions: +# contents: read +# concurrency: +# group: db-migrations-pr-${{ github.workflow }}-${{ github.head_ref || github.ref }} +# cancel-in-progress: true +# jobs: +# checks: +# uses: beplus/setup-beplus/.github/workflows/db-migrations-pr.yml@v2 +# with: +# github-environment: dev # only where BE_NPM_TOKEN is an environment secret +# secrets: inherit +# +# It reaches NO database but the Postgres service it starts: BE_POSTGRES_URL is +# localhost, no connection secret is read, and the only secret it uses is +# BE_NPM_TOKEN, for the private registry. It needs no AWS credentials. +# +# See cli_v2 EXTRACTION-PLAN.md, slice R2-4, and Linear BE-232. + +name: DB Migrations (PR-safe) + +on: + workflow_call: + inputs: + project: + description: >- + The Rush project of the database package — what `rush install --to` and + `rush build --to` name. Default: the package.json `name` of + beplus.estate.json → `database.package` (default + packages/modules/database), the package every `beplus db` command reads. + required: false + type: string + default: "" + github-environment: + description: >- + The GitHub environment the checks job claims, when BE_NPM_TOKEN is an + environment secret there (MRKIT: `dev`). Its branch policy must then + admit every branch this gate runs on. Empty (the default): none — the + token is a repository or organization secret. + required: false + type: string + default: "" + secrets: + BE_NPM_TOKEN: + description: >- + npm.beplus.cloud token for the @beplus scope. `secrets: inherit` (or, for + an environment secret, `github-environment`) is all a caller passes. + required: false + +permissions: + contents: read + +jobs: + # What the copies wrote out by hand, read from the manifest that already + # holds it. Both values come from the pull request's own tree, so both are + # validated here before any step pastes them. + plan: + name: plan + runs-on: ubuntu-latest + outputs: + package: ${{ steps.database.outputs.package }} + project: ${{ steps.database.outputs.project }} + database: ${{ steps.database.outputs.database }} + steps: + - uses: actions/checkout@v4 + + - name: Read the database package from the manifest + id: database + env: + INPUT_PROJECT: ${{ inputs.project }} + run: | + set -euo pipefail + manifest=beplus.estate.json + if [ ! -f "$manifest" ]; then + echo "::error::No beplus.estate.json at the repository root: its \`database\` section says which package the checks run over." + exit 1 + fi + + # The same default `beplus db` applies (cli-plugin-database's section). + package="$(jq -r '.database.package // "packages/modules/database"' "$manifest")" + if ! [[ "$package" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*)*$ ]] || [[ "/$package/" == */../* ]]; then + echo "::error file=$manifest::database.package \"$package\" is not a relative directory in this repository." + exit 1 + fi + if [ ! -f "$package/package.json" ]; then + echo "::error file=$manifest::database.package is \"$package\", which has no package.json — set it to the directory of the repository's database package." + exit 1 + fi + + project="${INPUT_PROJECT:-$(jq -r '.name // empty' "$package/package.json")}" + if ! [[ "$project" =~ ^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$ ]]; then + if [ -n "$INPUT_PROJECT" ]; then + echo "::error::the \`project\` input: \"$project\" is not a Rush project name." + else + echo "::error file=$package/package.json::\"$project\" is not a Rush project name." + fi + exit 1 + fi + + # mrkit_test, mcl_test, be_test — the names the copies gave it. + prefix="$(jq -r '.naming.prefix // empty' "$manifest")" + database="$(printf '%s' "${prefix:-${GITHUB_REPOSITORY#*/}}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_' '_')_test" + if ! [[ "$database" =~ ^[a-z_][a-z0-9_]{0,62}$ ]]; then + echo "::error file=$manifest::naming.prefix gives the test database \"$database\", which is not a Postgres identifier." + exit 1 + fi + + echo "database package $package ($project), test database $database" + { + echo "package=$package" + echo "project=$project" + echo "database=$database" + } >> "$GITHUB_OUTPUT" + + checks: + name: migration integrity + rebuild + needs: plan + runs-on: ubuntu-latest + # Where BE_NPM_TOKEN is an environment secret (MRKIT: `dev`) the job has to + # claim the environment to see it, and the environment's branch policy must + # then admit every branch this gate runs on — it has no `branches` filter + # by design. Empty: no environment. + environment: ${{ inputs.github-environment }} + timeout-minutes: 25 + + # pgvector image is required — the baseline migration runs CREATE EXTENSION + # vector. Pinned, not `pg16`: the server a rebuild runs on must not change + # under a pull request (the test kit, @beplus/database/testing, pins the same). + services: + postgres: + image: pgvector/pgvector:0.8.7-pg16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${{ needs.plan.outputs.database }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + env: + BE_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/${{ needs.plan.outputs.database }} + # A pinned pg16 client via docker, so dumps match the committed + # drizzle/schema.sql byte-for-byte regardless of the runner's installed + # pg_dump. WHICH image is the repository's: beplus.estate.json → + # database.dumpImage, else @beplus/database-tools' pin + # (pgvector/pgvector:pg16) — the same choice a laptop's `beplus db` makes, + # so it is not written here (BE_PGDUMP_IMAGE would outrank the manifest). + BE_PGDUMP_ENGINE: docker + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22.14 + + - name: Cache Rush and pnpm + uses: actions/cache@v4 + with: + path: | + common/temp + key: ${{ runner.os }}-${{ runner.arch }}-rush-${{ hashFiles('common/config/rush/pnpm-lock.yaml', 'rush.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rush- + + # @beplus/* lives on npm.beplus.cloud; this writes the token into + # ~/.npmrc. The same step installs the beplus CLI the repository pins + # (beplus.estate.json → cli.version) — a v2 CLI, which `beplus db check` + # needs; `vars.BE_CLI_VERSION` still wins when it is set. + - name: Authenticate the @beplus scope + uses: beplus/setup-beplus/cli@v2 + env: + BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} + BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} + with: + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} + BE_NPM_AUTH: true + # ⚠️ AND PROVE IT WORKED. npm.beplus.cloud answers a read it cannot + # authenticate with HTTP 200 and a stub body, never 401, so a token + # that did not reach this job fails nothing until pnpm, minutes later, + # reports ERR_PNPM_TARBALL_INTEGRITY, then ERR_PNPM_NO_VERSIONS. This + # asks the registry for every @beplus package the database package + # depends on, and fails here, naming the cause. + BE_NPM_CHECK: ${{ needs.plan.outputs.package }} + + - name: Rush install + run: node common/scripts/install-run-rush.js install --to ${{ needs.plan.outputs.project }} + + # ⚠️ INSTALL IS NOT ENOUGH. drizzle-kit reads dist/, including workspace + # dependencies' — Mountaineer's @beplus/postmark contributes tables. + # Without a build its dist/db is absent, drizzle-kit prints + # MODULE_NOT_FOUND, exits 0 anyway, and `db check pending` passes over a + # schema it never finished reading. MRKIT's copy always built; Mountaineer's + # did not, until it learnt this. + - name: Rush build + run: node common/scripts/install-run-rush.js build --to ${{ needs.plan.outputs.project }} + + # 1) Pure file check — journal/snapshot integrity + the `when`-ordering guard + # that catches a freshly-merged migration whose `when` slipped below an + # earlier one (the silent-skip hazard). No database needed. + # (The copies' `npm run db:check:collisions` was exactly this command.) + - name: Migration collision + ordering check + working-directory: ${{ needs.plan.outputs.package }} + run: beplus db check + + # 2) The TS schema must not have changes that lack a committed migration. + - name: No pending (uncommitted) migration + working-directory: ${{ needs.plan.outputs.package }} + run: beplus db check pending + + # 3) Every chain the product's migrator declaration embeds — @beplus/ai-db's + # beplus_ai, @beplus/analytics-db's analytics — resolves, and its + # journal is sound. Mountaineer's copy checked beplus_ai here; MRKIT's + # checked neither of its two (only its local `db:check` did). + - name: Embedded migration chains check + id: chains + working-directory: ${{ needs.plan.outputs.package }} + run: beplus db check chains + + # 4) A clean DB must rebuild from the migrations (every chain), AND the + # rebuilt structure must match the committed drizzle/schema.sql. + # If this fails only because the pinned pg16 image bumped minor versions, + # regenerate it: `beplus db migrate --url `, then + # `beplus db schema dump --url --write`. + - name: Rebuild from migrations + compare schema.sql + working-directory: ${{ needs.plan.outputs.package }} + run: beplus db check rebuild --compare diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index f380483..dd33260 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -12,6 +12,10 @@ # # `be` itself comes from beplus/be@main, so the pinned legs go green once # `be auto` reads beplus.estate.json there. +# +# BE_NPM_CHECK: with no token the auth is skipped and npm.beplus.cloud answers +# the @beplus read with HTTP 200 and its stub body, so the check must fail the +# run — before any install would have. name: Test cli on: @@ -60,6 +64,10 @@ jobs: - case: an inexact pin fails the run dir: test/cli/inexact expect: failure + - case: the registry check fails on the anonymous stub + dir: test/cli/pinned + npm-check: test/cli/npm-check + expect: failure steps: - uses: actions/checkout@v4 @@ -69,6 +77,11 @@ jobs: - name: Make a package directory below the pinned manifest run: mkdir -p test/cli/pinned/packages/app + # The scope mapped, no token: what a job that BE_NPM_TOKEN never reached sees. + - name: Map @beplus without a token + if: ${{ matrix.npm-check }} + run: echo "@beplus:registry=https://npm.beplus.cloud/" >> ~/.npmrc + - name: Setup beplus CLI id: setup uses: ./cli @@ -77,10 +90,13 @@ jobs: BE_CLI_VERSION: ${{ matrix.version }} # Empty `dir` = a directory with no beplus.estate.json at or above it. BE_WORKING_DIRECTORY: ${{ matrix.dir || runner.temp }} + BE_NPM_AUTH: ${{ matrix.npm-check && 'true' || 'false' }} + BE_NPM_CHECK: ${{ matrix.npm-check }} - name: Assert the installed version shell: bash env: + CASE: ${{ matrix.case }} EXPECT: ${{ matrix.expect }} OUTCOME: ${{ steps.setup.outcome }} run: | @@ -88,10 +104,10 @@ jobs: if [[ "$EXPECT" == failure ]]; then if [[ "$OUTCOME" != failure ]]; then - echo "::error::The action accepted a pin that is not an exact version (outcome: $OUTCOME)." + echo "::error::The action did not fail — $CASE (outcome: $OUTCOME)." exit 1 fi - echo "The action refused the inexact pin, as it must." + echo "The action failed, as it must: $CASE." exit 0 fi diff --git a/README.md b/README.md index abbba20..6e0d6c0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,83 @@ CLI the repository pins (`cli.version`). `node scripts/verify-callers.mjs infra- --repo ` proves a caller plus this workflow reproduce the file it replaced, step for step. +An estate's database gates are two more. MRKIT and Mountaineer each carried +`db-migrations-pr.yml` (146 / 137 lines) and `db-drift-env.yml` (105 / 91); +bepluscloud had neither. Both now run the repository's own `beplus db` commands — +`@beplus/database-tools`, resolved from the repository's install, never bundled +in the CLI — over `beplus.estate.json`'s `database` section: + +- **`db-migrations-pr.yml`** — on a pull request: a pgvector Postgres service + (`pgvector/pgvector:0.8.7-pg16`), the pinned CLI, `beplus npm auth` with the + stub-200 registry check (`BE_NPM_CHECK`, below), `rush install` / `rush build + --to `, then `beplus db check`, `db check pending`, + `db check chains` and `db check rebuild --compare`. It reaches no database but + its own service. +- **`db-drift-env.yml`** — nightly and on demand: `beplus db diff migrations + ` for each environment, then each environment against the next, all + aggregated so one drift does not hide another. Connection strings are the + secrets `BE_POSTGRES_URL_DEV` / `_STAGE` / `_PROD`, as before; there is no + database-read OIDC role in any account yet, nor a `db diff` through the tunnel, + so it assumes no AWS role. + +The rush project (`database.package` → its package.json `name`), the test +database (`naming.prefix` + `_test`) and the environments (`environments`) come +from the manifest; the inputs are for what cannot: + +| Input | Workflow | Default | | +|---|---|---|---| +| `github-environment` | both | none | The GitHub environment the job claims, when `BE_NPM_TOKEN` (and the connection strings) are environment secrets there — MRKIT: `dev` | +| `environments` | drift | `beplus.estate.json` → `environments` | e.g. `dev prod`; dev, stage and prod are mapped | +| `project` | both | `database.package`'s package.json `name` | The Rush project to install (and build) | + +```yaml +# .github/workflows/db-migrations-pr.yml — keep your own triggers +name: DB Migrations (PR-safe) +on: + pull_request: + paths: + - "packages/modules/database/drizzle/**" + - ".github/workflows/db-migrations-pr.yml" + # … the schema sources, drizzle.config.ts, package.json +permissions: + contents: read +concurrency: + group: db-migrations-pr-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true +jobs: + checks: + uses: beplus/setup-beplus/.github/workflows/db-migrations-pr.yml@v2 + secrets: inherit +``` + +```yaml +# .github/workflows/db-drift-env.yml +name: DB Drift (dev/prod) +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" +permissions: + contents: read +concurrency: + group: db-drift-env-${{ github.workflow }} + cancel-in-progress: false +jobs: + drift: + uses: beplus/setup-beplus/.github/workflows/db-drift-env.yml@v2 + secrets: inherit +``` + +Keep the job ids (`checks`, `drift`): the check is then named +`checks / migration integrity + rebuild` — a required status check that named +`migration integrity + rebuild` has to be renamed once. The pinned CLI must bundle +`@beplus/cli-plugin-database` with `db check pending | rebuild | chains` and the +repository must have installed `@beplus/database-tools`; `beplus db` refuses +cleanly otherwise. `node scripts/verify-callers.mjs db --repo ` proves a +caller plus these workflows reproduce the files it replaced: it runs the shared +plan step on the estate's own manifest, compares every job and step, and runs the +drift job's rewritten guard and comparison side by side with the copies'. + A repository's docs site — `@beplus/docs-site` from beplus/docs — is built and published to GitHub Pages by one more: diff --git a/cli/README.md b/cli/README.md index 9a87a56..bcb8e6c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -13,6 +13,7 @@ Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) - otherwise → `be latest`, as before, with a `::warning::` that the repository floats 3. Verifies `beplus --version` — and, for a pinned run, fails unless it is exactly the pinned version 4. Optionally runs `beplus npm auth` if enabled and required environment variables are present +5. Optionally (`BE_NPM_CHECK`) proves the registry then serves real `@beplus` packages — see [The registry never says no](#the-registry-never-says-no) --- @@ -22,6 +23,7 @@ Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) |------|----------|---------|-------------| | `BE_CLI_VERSION` | No | empty | Version or channel of beplus CLI to install (passed to `be`). Empty installs the repository's pin (`be auto`), or `latest` with a warning when there is none. A value here wins over the pin. | | `BE_NPM_AUTH` | No | `false` | If `true`, runs `beplus npm auth` | +| `BE_NPM_CHECK` | No | empty | Package directories (space- or newline-separated, relative to the workspace) whose `@beplus` dependencies must come back from the registry as real packages. Needs `npm` and `jq`. Empty: no check. | | `BE_PREFIX` | No | empty | Install prefix for beplus binaries. If empty, defaults to `$HOME/.beplus`. Must be writable. | | `BE_WORKING_DIRECTORY` | No | the workspace | Where the pin is looked up: the nearest `beplus.estate.json` at or above this directory, exactly as `be auto` finds it. Relative to the workspace. | @@ -82,6 +84,40 @@ Required only when `BE_NPM_AUTH` is set to `"true"`: - `BE_NPM_AUTH == "true"` - both `BE_NPM_TARGET` and `BE_NPM_TOKEN` are present +Otherwise it is skipped, and says so in the log — which is not an error, and +that is the trap below. + +### The registry never says no + +npm.beplus.cloud answers a read it cannot authenticate with **HTTP 200 and a stub +body** (`{"status":200,"message":"OK"}`), never 401. So a token that did not +reach the job — an environment secret in a job that claims no environment, a +fork's pull request — fails nothing here: `npm view` prints nothing and exits 0, +and pnpm fails minutes later with `ERR_PNPM_TARBALL_INTEGRITY`, then +`ERR_PNPM_NO_VERSIONS`. + +`BE_NPM_CHECK` closes that gap. After the auth, it asks the registry for every +`@beplus` dependency of the packages you name (`dependencies`, +`devDependencies`, `optionalDependencies`; `workspace:` ones are skipped) — +through the registry Rush hands pnpm (`common/config/rush/.npmrc`), else npm's +own — and fails the step unless each comes back as a real package, naming the +cause: the stub (no token, or a refused one), or npm's own error (a 404 for a +package that is not published). + +```yaml +- uses: actions/setup-node@v6 + with: + node-version: 22.14 + +- uses: beplus/setup-beplus/cli@v2 + env: + BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} + BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} + with: + BE_NPM_AUTH: true + BE_NPM_CHECK: packages/modules/database +``` + --- ## Usage diff --git a/cli/action.yml b/cli/action.yml index 42f5680..91a1089 100644 --- a/cli/action.yml +++ b/cli/action.yml @@ -13,6 +13,17 @@ inputs: description: "Authenticate beplus.cloud NPM? (requires BE_NPM_TARGET and BE_NPM_TOKEN env vars)" required: false default: "false" + BE_NPM_CHECK: + description: >- + Package directories (space- or newline-separated, relative to the workspace) + whose @beplus dependencies must come back from the registry as real packages + after `beplus npm auth`. npm.beplus.cloud answers a read it cannot + authenticate with HTTP 200 and a stub body — never 401 — so without this a + token that did not reach the job fails nothing until pnpm, minutes later, + reports ERR_PNPM_TARBALL_INTEGRITY. Needs npm and jq. Empty (the default): + no check. + required: false + default: "" BE_PREFIX: description: "Install prefix for beplus binaries (must be writable)." required: false @@ -163,3 +174,71 @@ runs: else echo "Skipping beplus npm auth (BE_NPM_AUTH=${{ inputs.BE_NPM_AUTH }} because one of (BE_NPM_TARGET,BE_NPM_TOKEN) is missing." fi + + # ⚠️ THE REGISTRY NEVER SAYS NO. npm.beplus.cloud answers a read it cannot + # authenticate with HTTP 200 and a stub body — {"status":200,"message":"OK"} + # — never 401, and the step above skips the auth (saying so in its log) when + # BE_NPM_TARGET or BE_NPM_TOKEN did not reach the job. `npm view` then prints + # nothing and exits 0; pnpm fails minutes later with ERR_PNPM_TARBALL_INTEGRITY, + # then ERR_PNPM_NO_VERSIONS. So ask, through the registry the install will use, + # for every @beplus package the named packages depend on, and fail here, + # naming the cause. + - name: Check the @beplus registry serves real packages + if: ${{ inputs.BE_NPM_CHECK != '' }} + shell: bash + working-directory: ${{ github.workspace }} + env: + CHECK: ${{ inputs.BE_NPM_CHECK }} + run: | + set -euo pipefail + command -v npm > /dev/null || { echo "::error::BE_NPM_CHECK needs npm on PATH (actions/setup-node first)."; exit 1; } + command -v jq > /dev/null || { echo "::error::BE_NPM_CHECK needs jq on PATH."; exit 1; } + + # The registry Rush hands pnpm for the scope; else npm's own (~/.npmrc, + # which `beplus npm auth` wrote). + registry="$(sed -n 's/^[[:space:]]*@beplus:registry[[:space:]]*=[[:space:]]*//p' common/config/rush/.npmrc 2> /dev/null | tail -n 1 | tr -d '[:space:]')" || registry="" + scope=() + if [[ "$registry" =~ ^https?://[^[:space:]\$]+$ ]]; then + scope=("--@beplus:registry=$registry") + else + registry="$(npm config get @beplus:registry)" + [ "$registry" != undefined ] || registry="npm's default registry (nothing maps @beplus)" + fi + + # Each package's direct @beplus dependencies. A workspace project is + # built, not fetched. + deps="" + for dir in $CHECK; do + if [ ! -f "$dir/package.json" ]; then + echo "::error::BE_NPM_CHECK: $dir has no package.json." + exit 1 + fi + deps+="$(jq -r ' + [.dependencies, .devDependencies, .optionalDependencies] + | map(objects | to_entries[]) | .[] + | select((.key | startswith("@beplus/")) and ((.value | tostring) | startswith("workspace:") | not)) + | .key' "$dir/package.json")"$'\n' + done + mapfile -t wanted < <(printf '%s' "$deps" | sed '/^$/d' | sort -u) + if [ "${#wanted[@]}" -eq 0 ]; then + echo "Nothing in @beplus to check ($CHECK)." + exit 0 + fi + + failed=0 + for pkg in "${wanted[@]}"; do + err="$(mktemp)" + rc=0 + name="$(npm view "$pkg" name "${scope[@]}" 2> "$err")" || rc=$? + if [ "$rc" -eq 0 ] && [ "$name" = "$pkg" ]; then + echo "✓ $pkg — a real package on $registry" + elif [ "$rc" -ne 0 ]; then + failed=1 + echo "::error::$pkg: $(grep -m 1 -E '^npm (error|ERR!) [0-9]{3} ' "$err" || grep -m 1 -E '^npm (error|ERR!)' "$err" || head -n 1 "$err")" + else + failed=1 + echo "::error::$pkg: $registry answered with its anonymous stub (HTTP 200, no package). BE_NPM_TOKEN did not reach this job, or the registry refused it — a repository or organization secret reaches it; an environment secret only reaches a job that claims that environment." + fi + rm -f "$err" + done + exit "$failed" diff --git a/scripts/verify-callers.mjs b/scripts/verify-callers.mjs index 4e5a174..517c0ec 100755 --- a/scripts/verify-callers.mjs +++ b/scripts/verify-callers.mjs @@ -1,13 +1,14 @@ #!/usr/bin/env node /** - * The caller verification gate (R2-1, R2-3). - * ------------------------------------------ + * The caller verification gate (R2-1, R2-3, R2-4). + * ------------------------------------------------- * A reusable workflow cannot be run locally, so this proves the next best thing: * that resolving the shared workflow WITH EACH CALLER'S INPUTS yields the same * enabled steps, in the same order, as the file that caller replaced. * - * The originals come from git — `HEAD~1` by default, or $BEFORE_REF — so this - * keeps working as a regression check across the commit that matters. + * The originals come from git — $BEFORE_REF, else (libraries) the parent of the + * commit that made each file a caller, else `HEAD~1` — so this keeps working as a + * regression check across the commit that matters. * * Usage: * @@ -26,21 +27,39 @@ * e.g. node scripts/verify-callers.mjs infra-diff \ * --repo ~/Git/github.com/bepluscloud/ai --ref origin/dev --caller /tmp/bepluscloud.yml * + * node scripts/verify-callers.mjs db --repo [--ref ] [--callers ] + * [--manifest ] [--repository ] [--repo …] + * Each estate's `.github/workflows/db-migrations-pr.yml` and `db-drift-env.yml` + * callers of the shared workflows of the same names (R2-4, BE-232). `--ref` + * is the commit whose copies the callers replaced (default $BEFORE_REF, else + * HEAD~1); a repository with no copy there (bepluscloud) GAINS the workflow, + * and only its caller and the manifest's derivation are checked. `--callers` + * is a directory holding callers not committed yet; the manifest and the + * database package are then read at `--ref` too, otherwise from the + * checkout. `--manifest` replaces beplus.estate.json (a section an adoption + * will add). Needs bash ≥ 4 and jq: the shared plan step and the rewritten + * guard / drift steps are RUN, side by side with the copies' steps. + * e.g. node scripts/verify-callers.mjs db \ + * --repo ~/Git/github.com/ironmountain-cz/monorepo --ref origin/dev --callers /tmp/db-callers/mountaineer + * * What it CANNOT prove: that the shared workflow runs green on GitHub. That * needs setup-beplus pushed and tagged, and one repository taken through a real * run. For the libraries do that with beplus/docs before the other eleven; for - * infra-diff, with one real pull request per estate, bepluscloud first. + * infra-diff and the db workflows, with one real pull request (one manual drift + * run) per estate, bepluscloud first. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; const HERE = new URL('..', import.meta.url).pathname; const usage = () => { console.error('usage: node scripts/verify-callers.mjs '); console.error(' node scripts/verify-callers.mjs infra-diff --repo [--ref ] [--caller ] [--repository ] [--repo …]'); + console.error(' node scripts/verify-callers.mjs db --repo [--ref ] [--callers ] [--manifest ] [--repository ] [--repo …]'); process.exit(2); }; @@ -100,14 +119,34 @@ const libraries = (ORG) => { .map((step) => step.name ?? `uses:${step.uses}`); }; + /** + * The commit whose file the caller replaced. `HEAD~1` was right only on the day + * each caller landed; every later commit made it compare a caller with a caller + * (and crash on its missing `steps`). So, unless $BEFORE_REF says otherwise: the + * parent of the oldest commit that wrote the `uses:` line in. + */ + const beforeRef = (dir, path, workflow) => { + if (process.env.BEFORE_REF) return process.env.BEFORE_REF; + const introduced = git(dir, 'log', '--format=%H', '-S', `setup-beplus/.github/workflows/${workflow}.yml`, '--', path) + .trim().split('\n').filter(Boolean).at(-1); + return introduced ? `${introduced}~1` : 'HEAD~1'; + }; + let failures = 0; for (const repo of REPOS) { for (const [file, workflow] of [['ci', 'library-ci'], ['publish', 'library-publish']]) { const path = `.github/workflows/${file}.yml`; - const before = YAML.parse(git(join(ORG, repo), 'show', `${process.env.BEFORE_REF ?? 'HEAD~1'}:${path}`)); + const ref = beforeRef(join(ORG, repo), path, workflow); + const before = YAML.parse(git(join(ORG, repo), 'show', `${ref}:${path}`)); const caller = YAML.parse(readFileSync(join(ORG, repo, path), 'utf8')); - const wanted = Object.values(before.jobs)[0].steps.map((s) => s.name ?? `uses:${s.uses}`); + const beforeSteps = Object.values(before.jobs)[0].steps; + if (!Array.isArray(beforeSteps)) { + failures += 1; + console.log(` ✗ ${repo}/${file} at ${ref} it is already a caller — set BEFORE_REF to the commit before the switch`); + continue; + } + const wanted = beforeSteps.map((s) => s.name ?? `uses:${s.uses}`); const got = enabledSteps(shared[workflow], Object.values(caller.jobs)[0].with ?? {}); if (JSON.stringify(wanted) === JSON.stringify(got)) { @@ -445,9 +484,600 @@ const infraDiff = (argv) => { process.exit(divergent === 0 ? 0 : 1); }; +// ═════════════════════════════════════════════════════════════════════════════ +// R2-4 — db-migrations-pr.yml and db-drift-env.yml in the estates (BE-232) +// ═════════════════════════════════════════════════════════════════════════════ +// +// The infra-diff method — the shared jobs, resolved for an estate, against the +// copy's jobs, whole: every job key, every step, every byte — with two additions: +// +// 1. The values the shared workflow derives are not re-implemented here. Its +// `plan` step is RUN, on the estate's own beplus.estate.json and database +// package.json, and its outputs are what `${{ needs.plan.outputs.* }}` +// resolves to. A derivation that is wrong for an estate shows up as a job or +// step that no longer matches the copy. +// 2. Where a step body became a loop over the manifest's environments (the +// drift job's guard and comparison), text cannot match. Those steps are run +// side by side with the copy's — stub `npm` / `beplus`, every scenario that +// changes their output — and must print the same and exit the same. +// +// Every other difference is declared below with its reason, and reported; an +// undeclared one is divergent. + +const DB_WORKFLOWS = ['db-migrations-pr', 'db-drift-env']; +const CLI_ACTION = /^beplus\/setup-beplus\/cli@/; +const AUTH_STEP = 'Authenticate the @beplus scope'; +const PINNED_CLI = '${{ vars.BE_CLI_VERSION }}'; +const TOOLS_DUMP_IMAGE = 'pgvector/pgvector:pg16'; +const PINNED_SERVER = 'pgvector/pgvector:0.8.7-pg16'; + +/** Rewrites of the COPY's step: what the shared workflow does differently on purpose. `null`: not this step. */ +const COMMON_STEP_INTENDED = [ + { + what: "CLI: the repository's pin (`be auto`), not `vars.BE_CLI_VERSION || '2'` / `latest` — BE-213", + apply: (s) => CLI_ACTION.test(s.uses ?? '') && ["${{ vars.BE_CLI_VERSION || '2' }}", 'latest'].includes(s.with?.BE_CLI_VERSION) + ? { ...s, with: { ...s.with, BE_CLI_VERSION: PINNED_CLI } } : null, + }, + { + what: `named "${AUTH_STEP}", as MRKIT's copy names it`, + apply: (s) => CLI_ACTION.test(s.uses ?? '') && s.name === undefined ? { ...s, name: AUTH_STEP } : null, + }, +]; + +/** Additions on the SHARED side, stripped before comparing. `null`: not there. */ +const COMMON_STEP_ADDED = [ + { + what: 'BE_NPM_CHECK: the stub-200 registry check (the @beplus packages the database package needs come back as real packages)', + strip: (s) => { + if (!CLI_ACTION.test(s.uses ?? '') || s.with?.BE_NPM_CHECK === undefined) return null; + const { BE_NPM_CHECK: _check, ...rest } = s.with; + return { ...s, with: rest }; + }, + }, +]; + +const runRewrite = (from, to, what) => ({ what, apply: (s) => (s.run === from ? { ...s, run: to } : null) }); + +const DB = { + 'db-migrations-pr': { + jobs: { checks: 'checks' }, + jobIntended: [ + { + what: `Postgres service ${PINNED_SERVER}, not the floating pg16 tag — a rebuild's server cannot change under a pull request`, + apply: (j) => (j.services?.postgres?.image === TOOLS_DUMP_IMAGE + ? { ...j, services: { ...j.services, postgres: { ...j.services.postgres, image: PINNED_SERVER } } } : null), + }, + { + what: `BE_PGDUMP_IMAGE unset: the repository's database.dumpImage, else @beplus/database-tools' pin — ${TOOLS_DUMP_IMAGE}, this value`, + apply: (j) => (j.env?.BE_PGDUMP_IMAGE === TOOLS_DUMP_IMAGE + ? { ...j, env: Object.fromEntries(Object.entries(j.env).filter(([k]) => k !== 'BE_PGDUMP_IMAGE')) } : null), + }, + ], + stepIntended: [ + ...COMMON_STEP_INTENDED, + runRewrite('npm run db:check:collisions', 'beplus db check', 'the npm script\'s own command — `db:check:collisions` was `beplus db check`'), + runRewrite('npm run db:check:pending', 'beplus db check pending', '`beplus db check pending` — @beplus/database-tools, the copies\' bin/db-check-pending.ts (BE-228; parity BE-231)'), + runRewrite('npm run db:check:rebuild -- --compare', 'beplus db check rebuild --compare', '`beplus db check rebuild --compare` — @beplus/database-tools, the copies\' bin/db-check-rebuild.ts (BE-228; parity BE-231)'), + { + what: '`beplus db check chains` — every chain the declaration embeds, the copy\'s bin/db-check-ai-chain.ts among them (BE-228)', + apply: (s) => (s.run === 'npm run db:check:ai-chain' + ? { ...s, name: 'Embedded migration chains check', run: 'beplus db check chains' } : null), + }, + ], + stepAdded: COMMON_STEP_ADDED, + addedIfAbsent: { + chains: { + why: 'the declaration\'s chains, which this copy never checked in CI (MRKIT: beplus_ai + analytics, only in its local `db:check`)', + present: (s) => /^npm run db:check:[a-z]+-chain$/.test(String(s.run ?? '')), + }, + }, + removedIfPresent: [], + behaviour: {}, + }, + 'db-drift-env': { + jobs: { drift: 'drift' }, + jobIntended: [ + { + what: `BE_PGDUMP_IMAGE unset: the repository's database.dumpImage, else @beplus/database-tools' pin — ${TOOLS_DUMP_IMAGE}, this value`, + apply: (j) => (j.env?.BE_PGDUMP_IMAGE === TOOLS_DUMP_IMAGE + ? { ...j, env: Object.fromEntries(Object.entries(j.env).filter(([k]) => k !== 'BE_PGDUMP_IMAGE')) } : null), + }, + ], + jobAdded: [ + { + what: 'BE_POSTGRES_URL_STAGE mapped: the stage leg of an estate that lists stage (MRKIT, bepluscloud)', + strip: (j) => (j.env?.BE_POSTGRES_URL_STAGE === '${{ secrets.BE_POSTGRES_URL_STAGE }}' + ? { ...j, env: Object.fromEntries(Object.entries(j.env).filter(([k]) => k !== 'BE_POSTGRES_URL_STAGE')) } : null), + }, + ], + stepIntended: COMMON_STEP_INTENDED, + stepAdded: COMMON_STEP_ADDED, + addedIfAbsent: {}, + removedIfPresent: [ + { + what: 'no build: `beplus db diff` reads the committed schema file and the databases, no compiled schema (Mountaineer\'s copy never built)', + match: (s) => s.name === 'Rush build' && /^node common\/scripts\/install-run-rush\.js build --to \S+$/.test(String(s.run ?? '')), + }, + ], + // Steps whose body became a loop over the environments: run side by side. + behaviour: { + guard: { added: ['ENVIRONMENTS'], scenarios: 'guard' }, + drift: { added: ['ENVIRONMENTS', 'SUMMARY'], scenarios: 'drift' }, + }, + }, +}; + +/** What the shared workflow may do at all, whoever calls it. */ +const dbSafety = (name, shared, raw) => { + const failures = []; + const triggers = Object.keys(shared.on ?? {}); + if (triggers.join() !== 'workflow_call') failures.push(`triggers are [${triggers}], not just workflow_call`); + if (canon(shared.permissions) !== canon({ contents: 'read' })) failures.push(`permissions ${canon(shared.permissions)} — contents: read is all it needs`); + for (const [jobName, job] of Object.entries(shared.jobs ?? {})) { + if (job.permissions !== undefined) failures.push(`job "${jobName}" sets its own permissions`); + for (const step of job.steps ?? []) { + if (/configure-aws-credentials/.test(String(step.uses ?? ''))) failures.push(`job "${jobName}" assumes an AWS role`); + } + } + const secrets = [...new Set([...raw.matchAll(/secrets\.([A-Z0-9_]+)/g)].map((m) => m[1]))]; + const allowed = name === 'db-migrations-pr' + ? ['BE_NPM_TOKEN'] + : ['BE_NPM_TOKEN', 'BE_POSTGRES_URL_DEV', 'BE_POSTGRES_URL_STAGE', 'BE_POSTGRES_URL_PROD']; + for (const secret of secrets.filter((x) => !allowed.includes(x))) failures.push(`reads secrets.${secret}`); + // What runs, not what the comments say about other commands. + const code = raw.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); + const commands = [...code.matchAll(/\bbeplus\s+db\s+([a-z-]+(?:\s+[a-z-]+)?)/g)].map((m) => m[1]); + for (const command of commands) { + const ok = name === 'db-migrations-pr' ? /^check(\s|$)/.test(command) : /^diff(\s|$)/.test(command); + if (!ok) failures.push(`runs \`beplus db ${command}\` — ${name === 'db-migrations-pr' ? 'only `db check …`' : 'only `db diff`'} belongs here`); + } + if (/--write\b/.test(code)) failures.push('writes a schema file (--write)'); + if (name === 'db-migrations-pr') { + const url = String(shared.jobs?.checks?.env?.BE_POSTGRES_URL ?? ''); + if (!/^postgres:\/\/[^@]+@localhost:5432\//.test(url)) failures.push(`BE_POSTGRES_URL is "${url}", not the job's own localhost service`); + } + return failures; +}; + +const parseDbArgs = (argv) => { + const entries = []; + for (let i = 0; i < argv.length; i += 2) { + const [flag, value] = [argv[i], argv[i + 1]]; + if (value === undefined) usage(); + if (flag === '--repo') { entries.push({ dir: resolve(value) }); continue; } + const current = entries.at(-1); + if (!current) usage(); + if (flag === '--ref') current.ref = value; + else if (flag === '--callers') current.callers = resolve(value); + else if (flag === '--manifest') current.manifest = resolve(value); + else if (flag === '--repository') current.repository = value; + else usage(); + } + if (entries.length === 0) usage(); + return entries; +}; + +/** A file at `ref` (or in the checkout when `ref` is undefined); undefined when it is not there. */ +const readAt = (dir, ref, path) => { + try { + return ref + ? execFileSync('git', ['-C', dir, 'show', `${ref}:${path}`], { encoding: 'utf8', maxBuffer: 1e8, stdio: ['ignore', 'pipe', 'ignore'] }) + : readFileSync(join(dir, path), 'utf8'); + } catch { + return undefined; + } +}; + +/** Replace `${{ needs.plan.outputs.x }}` and `${{ inputs.x }}` in every string; collect what was used. */ +const EXPRESSION = /\$\{\{\s*(needs\.plan\.outputs|inputs)\.([A-Za-z0-9_-]+)\s*\}\}/g; +const resolveExpressions = (value, scope, used) => { + if (typeof value === 'string') { + return value.replace(EXPRESSION, (whole, where, key) => { + const table = where === 'inputs' ? scope.inputs : scope.outputs; + if (!Object.hasOwn(table, key)) { used.unknown.add(whole); return whole; } + used.keys.add(`${where === 'inputs' ? 'inputs' : 'plan'}.${key}`); + return table[key]; + }); + } + if (Array.isArray(value)) return value.map((v) => resolveExpressions(v, scope, used)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveExpressions(v, scope, used)])); + } + return value; +}; + +const bashOk = () => { + const r = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}"'], { encoding: 'utf8' }); + return Number(r.stdout.trim()) >= 4 && spawnSync('jq', ['--version']).status === 0; +}; + +/** Run a `run:` body as GitHub does when no shell is given (`bash -e {0}`). */ +const runBody = (body, { cwd, env }) => { + const file = join(cwd, `.step-${Math.random().toString(36).slice(2)}.sh`); + writeFileSync(file, body); + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', file], { cwd, env, encoding: 'utf8' }); + rmSync(file, { force: true }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +}; + +/** + * The shared workflow's `plan` step, run as GitHub would run it on the estate's tree: + * beplus.estate.json and the database package's package.json in a scratch directory. + */ +const runPlan = (shared, inputs, files, repository) => { + const plan = shared.jobs?.plan; + const step = plan?.steps?.find((s) => s.id === 'database'); + if (!step) return { error: 'the shared workflow has no plan job with a `database` step' }; + const sandbox = mkdtempSync(join(tmpdir(), 'verify-callers-plan-')); + try { + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(sandbox, path)), { recursive: true }); + writeFileSync(join(sandbox, path), text); + } + const output = join(sandbox, '.github-output'); + writeFileSync(output, ''); + const used = { keys: new Set(), unknown: new Set() }; + const stepEnv = resolveExpressions(step.env ?? {}, { inputs, outputs: {} }, used); + const r = runBody(step.run, { + cwd: sandbox, + env: { PATH: process.env.PATH, HOME: sandbox, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: repository, ...stepEnv }, + }); + if (r.status !== 0) return { error: `the plan step failed (exit ${r.status}): ${(r.stdout + r.stderr).trim().split('\n').slice(-2).join(' / ')}` }; + const stepOutputs = Object.fromEntries(readFileSync(output, 'utf8').split('\n').filter(Boolean).map((line) => { + const at = line.indexOf('='); + return [line.slice(0, at), line.slice(at + 1)]; + })); + const outputs = Object.fromEntries(Object.entries(plan.outputs ?? {}).map(([key, expr]) => { + const m = String(expr).match(/^\$\{\{\s*steps\.database\.outputs\.([A-Za-z0-9_-]+)\s*\}\}$/); + return [key, m ? stepOutputs[m[1]] ?? '' : String(expr)]; + })); + return { outputs, log: r.stdout.trim() }; + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +}; + +/** The stubs a rewritten step runs against: the copy's `npm run db:diff -- a b` and the shared `beplus db diff a b`. */ +const STUBS = { + npm: '#!/usr/bin/env bash\nif [ "$1" = run ] && [ "$2" = db:diff ] && [ "$3" = -- ]; then exec "$STUB_DIFF" npm "$4" "$5"; fi\necho "unexpected: npm $*" >&2; exit 97\n', + beplus: '#!/usr/bin/env bash\nif [ "$1" = db ] && [ "$2" = diff ]; then exec "$STUB_DIFF" beplus "$3" "$4"; fi\necho "unexpected: beplus $*" >&2; exit 97\n', + // The bins exit 1 on drift; the CLI turns a failed program into exit 2. The aggregation must not care. + diff: '#!/usr/bin/env bash\necho "[db:diff] $2 vs $3"\necho "[db:diff] (stderr) $2 vs $3" >&2\ncase ",$STUB_FAIL," in *",$2:$3,"*) if [ "$1" = beplus ]; then exit 2; else exit 1; fi ;; esac\nexit 0\n', +}; + +/** Every scenario that changes what a guard or drift step prints, for these environments. */ +const scenarios = (kind, envs) => { + const url = (env) => `BE_POSTGRES_URL_${env.toUpperCase()}`; + const all = Object.fromEntries(envs.map((e) => [url(e), `postgres://reader:secret@${e}.example:5432/db`])); + if (kind === 'guard') { + return [ + { name: 'every connection string set', env: all }, + ...envs.map((e) => ({ name: `${url(e)} unset`, env: Object.fromEntries(Object.entries(all).filter(([k]) => k !== url(e))) })), + ...envs.map((e) => ({ name: `${url(e)} empty`, env: { ...all, [url(e)]: '' } })), + { name: 'none set', env: {} }, + ]; + } + const pairs = [...envs.map((e) => `migrations:${e}`), ...envs.slice(1).map((e, i) => `${envs[i]}:${e}`)]; + return [ + { name: 'no drift', env: { ...all, STUB_FAIL: '' } }, + ...pairs.map((p) => ({ name: `drift in ${p.replace(':', ' vs ')}`, env: { ...all, STUB_FAIL: p } })), + { name: 'drift everywhere', env: { ...all, STUB_FAIL: pairs.join(',') } }, + ]; +}; + +/** Run the copy's step and the shared step under each scenario; the transcripts must be equal. */ +const behaves = (kind, envs, original, shared, jobEnvOriginal, jobEnvShared) => { + const sandbox = mkdtempSync(join(tmpdir(), 'verify-callers-steps-')); + try { + const bin = join(sandbox, 'bin'); + mkdirSync(bin); + for (const [name, text] of Object.entries(STUBS)) { + writeFileSync(join(bin, name), text); + chmodSync(join(bin, name), 0o755); + } + const base = { PATH: `${bin}:/usr/bin:/bin:${process.env.PATH}`, HOME: sandbox, STUB_DIFF: join(bin, 'diff') }; + // A job's connection strings come from its `env`; the scenario decides which are set. + const strip = (env) => Object.fromEntries(Object.entries(env ?? {}).filter(([k]) => !/^BE_POSTGRES_URL_/.test(k))); + const results = []; + for (const scenario of scenarios(kind, envs)) { + const before = runBody(original.run, { cwd: sandbox, env: { ...base, ...strip(jobEnvOriginal), ...(original.env ?? {}), ...scenario.env } }); + const after = runBody(shared.run, { cwd: sandbox, env: { ...base, ...strip(jobEnvShared), ...(shared.env ?? {}), ...scenario.env } }); + const same = before.status === after.status && before.stdout === after.stdout && before.stderr === after.stderr; + results.push({ scenario: scenario.name, same, before, after }); + } + return results; + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +}; + +const without = (object, keys) => Object.fromEntries(Object.entries(object ?? {}).filter(([k]) => !keys.includes(k))); + +/** Apply each declared rewrite that matches; return the result and what applied. */ +const rewrite = (value, declarations, key) => { + const applied = []; + let current = value; + for (const d of declarations) { + const next = d[key](current); + if (next) { current = next; applied.push(d.what); } + } + return { value: current, applied }; +}; + +const databaseWorkflows = (argv) => { + const entries = parseDbArgs(argv); + if (!bashOk()) { + console.error('verify-callers db needs bash ≥ 4 and jq on PATH (GitHub\'s runners have both): it runs the shared plan step and the rewritten steps.'); + process.exit(2); + } + const YAML = loadYaml([ + ...entries.map((e) => e.dir), + join(HERE, '..', '..', 'mrkithq', 'monorepo'), + join(HERE, '..', '..', 'bepluscloud', 'ai'), + ]); + + const shared = {}; + const sharedRaw = {}; + let problemsTotal = 0; + const totals = { identical: 0, derived: 0, intended: 0, added: 0, removed: 0, behaviour: 0, divergent: 0 }; + + for (const name of DB_WORKFLOWS) { + sharedRaw[name] = readFileSync(join(HERE, '.github', 'workflows', `${name}.yml`), 'utf8'); + shared[name] = YAML.parse(sharedRaw[name]); + console.log(`${name}.yml (shared)`); + const failures = dbSafety(name, shared[name], sharedRaw[name]); + for (const failure of failures) console.log(` ✗ ${failure}`); + if (failures.length === 0) { + console.log(name === 'db-migrations-pr' + ? ' ✓ workflow_call only; contents: read; no AWS; BE_NPM_TOKEN its only secret; `beplus db check …` only, against its own localhost service' + : ' ✓ workflow_call only; contents: read; no AWS; BE_NPM_TOKEN + BE_POSTGRES_URL_DEV/STAGE/PROD its only secrets; `beplus db diff` only, nothing written'); + } + problemsTotal += failures.length; + } + + for (const entry of entries) { + const ref = entry.ref ?? process.env.BEFORE_REF ?? 'HEAD~1'; + const repository = entry.repository ?? repositoryOf(entry.dir); + // Callers not committed yet run against the tree their originals came from. + const treeRef = entry.callers ? ref : undefined; + const manifestText = entry.manifest ? readFileSync(entry.manifest, 'utf8') : readAt(entry.dir, treeRef, 'beplus.estate.json'); + console.log(); + console.log(`${repository}`); + if (manifestText === undefined) { + console.log(` ✗ no beplus.estate.json${treeRef ? ` at ${treeRef}` : ''}`); + problemsTotal += 1; + continue; + } + const manifest = JSON.parse(manifestText); + // Which package.json the plan step will look for — the step itself decides, and fails if this guess is wrong. + const packageDir = manifest.database?.package ?? 'packages/modules/database'; + const packageJson = readAt(entry.dir, treeRef, `${packageDir}/package.json`); + const files = { 'beplus.estate.json': manifestText, ...(packageJson === undefined ? {} : { [`${packageDir}/package.json`]: packageJson }) }; + console.log(` manifest ${entry.manifest ?? `beplus.estate.json${treeRef ? ` at ${treeRef}` : ''}`}; copies at ${ref}`); + + for (const name of DB_WORKFLOWS) { + const decl = DB[name]; + const path = join('.github', 'workflows', `${name}.yml`); + const callerPath = entry.callers ? join(entry.callers, `${name}.yml`) : join(entry.dir, path); + const originalText = readAt(entry.dir, ref, path); + const problems = []; + const counts = { identical: 0, derived: 0, intended: 0, added: 0, removed: 0, behaviour: 0, divergent: 0 }; + const sharedWorkflow = shared[name]; + const declaredInputs = sharedWorkflow.on?.workflow_call?.inputs ?? {}; + + console.log(); + console.log(` ${name}.yml — ${originalText === undefined ? `GAINED (no copy at ${ref})` : `replaces the copy at ${ref}`}; caller ${callerPath}`); + if (!existsSync(callerPath)) { + console.log(' ✗ no caller'); + problemsTotal += 1; + continue; + } + const caller = YAML.parse(readFileSync(callerPath, 'utf8')); + const original = originalText === undefined ? undefined : YAML.parse(originalText); + + // ── The caller: what stays in the repository, exactly as it was ─────── + const KEPT = ['name', 'on', 'permissions', 'concurrency']; + for (const key of Object.keys(caller)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`caller: top-level \`${key}\` — nothing but ${KEPT.join(', ')} and the call belongs here`); + } + if (original) { + for (const key of Object.keys(original)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`original: top-level \`${key}\` has no home in caller or shared workflow`); + } + for (const key of KEPT) { + if (canon(caller[key]) !== canon(original[key])) { + problems.push(`caller: \`${key}\` differs from the original\n had: ${canon(original[key])}\n gets: ${canon(caller[key])}`); + } + } + } else { + for (const key of KEPT) if (caller[key] === undefined) problems.push(`caller: no \`${key}\``); + } + // The jobs run with what the caller grants; a called workflow can only narrow it. + if (canon(caller.permissions) !== canon(sharedWorkflow.permissions)) { + problems.push(`caller: permissions ${canon(caller.permissions)} ≠ the shared workflow's ${canon(sharedWorkflow.permissions)} — grant exactly that`); + } + const callerJobs = Object.entries(caller.jobs ?? {}); + if (callerJobs.length !== 1) problems.push(`caller: ${callerJobs.length} jobs — exactly one, the call, belongs here`); + const [callerJobName, callerJob = {}] = callerJobs[0] ?? []; + const callPattern = new RegExp(`^beplus/setup-beplus/\\.github/workflows/${name}\\.yml@\\S+$`); + if (!callPattern.test(String(callerJob.uses ?? ''))) problems.push(`caller: job "${callerJobName}" does not call beplus/setup-beplus/.github/workflows/${name}.yml`); + const originalJobName = original ? Object.keys(original.jobs ?? {})[0] : Object.keys(decl.jobs)[0]; + if (callerJobName !== originalJobName) { + problems.push(`caller: job id "${callerJobName}" — keep "${originalJobName}", so the check is still "${originalJobName} / …"`); + } + for (const key of Object.keys(callerJob.with ?? {})) { + if (!Object.hasOwn(declaredInputs, key)) problems.push(`caller: passes \`${key}\`, which the shared workflow does not declare — GitHub refuses the run`); + } + if (callerJob.secrets !== 'inherit') problems.push('caller: `secrets: inherit` — the token (and the connection strings) are the repository\'s'); + for (const key of Object.keys(callerJob)) { + if (!['uses', 'with', 'secrets'].includes(key)) problems.push(`caller: job "${callerJobName}" sets \`${key}\` — the shared jobs own it`); + } + const inputs = Object.fromEntries(Object.entries(declaredInputs).map(([key, spec]) => [key, String(callerJob.with?.[key] ?? spec.default ?? '')])); + const passed = Object.entries(callerJob.with ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') || 'no inputs'; + if (problems.length === 0) { + console.log(` ✓ caller ${original ? `${KEPT.join(', ')} identical to the original` : 'its own triggers'}; one job "${callerJobName}", ${callerJob.uses}, ${passed}, secrets: inherit`); + } + + // ── The derivation: the plan step, run on this estate's tree ────────── + const plan = runPlan(sharedWorkflow, inputs, files, repository); + if (plan.error) { + problems.push(`plan: ${plan.error}`); + for (const problem of problems) console.log(` ✗ ${problem}`); + problemsTotal += problems.length; + continue; + } + console.log(` ✓ plan ${Object.entries(plan.outputs).map(([k, v]) => `${k}=${v}`).join(' · ')}`); + const scope = { inputs, outputs: plan.outputs }; + + if (!original) { + // Nothing to compare with: the caller, the derivation, and that every expression resolves. + const used = { keys: new Set(), unknown: new Set() }; + resolveExpressions(sharedWorkflow.jobs, scope, used); + for (const unknown of used.unknown) problems.push(`shared: ${unknown} resolves to nothing`); + for (const problem of problems) console.log(` ✗ ${problem}`); + console.log(` gained: ${Object.keys(sharedWorkflow.jobs).length} jobs, ${Object.values(sharedWorkflow.jobs).reduce((n, j) => n + (j.steps?.length ?? 0), 0)} steps${problems.length ? ` — ${problems.length} problem(s)` : ''}`); + problemsTotal += problems.length; + continue; + } + + // ── The jobs: the shared workflow, resolved for this estate, vs the copy ─ + const sharedJobs = Object.keys(sharedWorkflow.jobs ?? {}); + const originalJobs = Object.keys(original.jobs ?? {}); + for (const job of sharedJobs.filter((j) => j !== 'plan' && !Object.values(decl.jobs).includes(j))) problems.push(`jobs: "${job}" is in the shared workflow and in no declaration`); + for (const job of originalJobs.filter((j) => !Object.hasOwn(decl.jobs, j))) problems.push(`jobs: the original's "${job}" has no shared counterpart`); + console.log(` + job plan (reads the manifest; ${Object.keys(plan.outputs).join(', ')})`); + + for (const [originalJobName_, sharedJobName] of Object.entries(decl.jobs)) { + if (!original.jobs?.[originalJobName_] || !sharedWorkflow.jobs?.[sharedJobName]) continue; + const used = { keys: new Set(), unknown: new Set() }; + const { steps: rawSteps = [], ...rawJob } = sharedWorkflow.jobs[sharedJobName]; + const sharedJobRaw = resolveExpressions(rawJob, scope, used); + const { steps: originalSteps = [], ...originalJobRaw } = original.jobs[originalJobName_]; + for (const unknown of used.unknown) problems.push(`job "${sharedJobName}": ${unknown} resolves to nothing`); + + // Job keys. + const notes = []; + let sharedJob = sharedJobRaw; + if (sharedJob.needs === 'plan') { sharedJob = without(sharedJob, ['needs']); notes.push('needs: plan'); } + if (sharedJob.environment === '') { sharedJob = without(sharedJob, ['environment']); notes.push('no github-environment: no environment, as the copy'); } + const added = rewrite(sharedJob, decl.jobAdded ?? [], 'strip'); + const intended = rewrite(originalJobRaw, decl.jobIntended ?? [], 'apply'); + if (canon(added.value) !== canon(intended.value)) { + problems.push(`job "${sharedJobName}": job keys differ\n had: ${canon(intended.value)}\n gets: ${canon(added.value)}`); + } + console.log(` job ${sharedJobName} (${[...notes, ...added.applied.map((w) => `+ ${w}`), ...intended.applied.map((w) => `~ ${w}`)].join('; ') || 'keys identical'})`); + + // Steps, in order. + const behaviour = decl.behaviour ?? {}; + let next = 0; + const report = (mark, label, why) => console.log(` ${mark} ${label.padEnd(58)} ${why}`); + const skipRemoved = () => { + while (next < originalSteps.length) { + const removal = (decl.removedIfPresent ?? []).find((r) => r.match(originalSteps[next])); + if (!removal) break; + counts.removed += 1; + report('−', stepKey(originalSteps[next]), `removed — ${removal.what}`); + next += 1; + } + }; + for (const [i, raw] of rawSteps.entries()) { + skipRemoved(); + const stepUsed = { keys: new Set(), unknown: new Set() }; + const step = resolveExpressions(raw, scope, stepUsed); + for (const unknown of stepUsed.unknown) problems.push(`job "${sharedJobName}" step ${i + 1}: ${unknown} resolves to nothing`); + const id = step.id; + const ifAbsent = id ? decl.addedIfAbsent?.[id] : undefined; + if (ifAbsent && !originalSteps.some(ifAbsent.present)) { + counts.added += 1; + report('+', stepKey(step), `added — ${ifAbsent.why}`); + continue; + } + const theirs = originalSteps[next++]; + if (!theirs) { + counts.divergent += 1; + problems.push(`job "${sharedJobName}" step ${i + 1}: only in the shared workflow — ${stepKey(step)}`); + report('✗', stepKey(step), 'only in the shared workflow'); + continue; + } + const mine = without(step, ['id']); // an id is a label; nothing in these jobs reads one + const plain = without(raw, ['id']); + if (canon(plain) === canon(theirs)) { + counts.identical += 1; + report('✓', stepKey(step), 'identical'); + continue; + } + const stripped = rewrite(mine, decl.stepAdded ?? [], 'strip'); + const target = rewrite(theirs, decl.stepIntended ?? [], 'apply'); + const spec = id ? behaviour[id] : undefined; + if (spec) { + // Everything but the body must match; the body must BEHAVE the same. + const structural = { ...stripped.value, env: without(stripped.value.env, spec.added), run: undefined }; + const structuralTarget = { ...target.value, run: undefined }; + if (Object.keys(structural.env).length === 0) delete structural.env; + if (canon(structural) !== canon(structuralTarget)) { + counts.divergent += 1; + problems.push(`job "${sharedJobName}" step ${i + 1} "${stepKey(mine)}" differs beyond its body\n had: ${canon(structuralTarget)}\n gets: ${canon(structural)}`); + report('✗', stepKey(mine), 'DIVERGENT (keys)'); + continue; + } + const envs = String(plan.outputs.environments ?? '').split(' ').filter(Boolean); + const runs = behaves(spec.scenarios, envs, theirs, mine, originalJobRaw.env, sharedJobRaw.env); + const bad = runs.filter((r) => !r.same); + if (bad.length) { + counts.divergent += 1; + for (const r of bad) { + problems.push(`job "${sharedJobName}" step ${i + 1} "${stepKey(mine)}" behaves differently — ${r.scenario}\n had: exit ${r.before.status} ${JSON.stringify(r.before.stdout + r.before.stderr)}\n gets: exit ${r.after.status} ${JSON.stringify(r.after.stdout + r.after.stderr)}`); + } + report('✗', stepKey(mine), `DIVERGENT in ${bad.length}/${runs.length} scenarios`); + } else { + counts.behaviour += 1; + const why = [`run side by side: same output and exit in ${runs.length} scenarios (${spec.scenarios})`, ...target.applied, ...stripped.applied.map((w) => `+ ${w}`)]; + report('≡', stepKey(mine), why.join('; ')); + } + continue; + } + if (canon(stripped.value) === canon(target.value)) { + const why = [...target.applied.map((w) => `intended: ${w}`), ...stripped.applied.map((w) => `+ ${w}`)]; + if (target.applied.length || stripped.applied.length) counts.intended += 1; else counts.derived += 1; + report(target.applied.length || stripped.applied.length ? '~' : '✓', stepKey(mine), + [why.join('; '), stepUsed.keys.size ? `derived: ${[...stepUsed.keys].join(', ')}` : ''].filter(Boolean).join('; ')); + continue; + } + counts.divergent += 1; + problems.push(`job "${sharedJobName}" step ${i + 1} "${stepKey(mine)}" differs\n had: ${canon(target.value)}\n gets: ${canon(stripped.value)}`); + report('✗', stepKey(mine), 'DIVERGENT'); + } + skipRemoved(); + for (; next < originalSteps.length; next += 1) { + counts.divergent += 1; + problems.push(`job "${sharedJobName}": the original's step "${stepKey(originalSteps[next])}" is missing from the shared workflow`); + report('✗', stepKey(originalSteps[next]), 'MISSING from the shared workflow'); + } + } + + for (const problem of problems) console.log(` ✗ ${problem}`); + const steps = Object.values(counts).reduce((a, b) => a + b, 0); + console.log(` ${steps} steps: ${counts.identical} identical, ${counts.derived} identical once derived, ${counts.intended} intended, ` + + `${counts.behaviour} rewritten and run side by side, ${counts.added} added, ${counts.removed} removed, ${counts.divergent} divergent` + + `${problems.length ? ` — ${problems.length} problem(s)` : ''}`); + problemsTotal += problems.length; + for (const k of Object.keys(totals)) totals[k] += counts[k]; + } + } + + console.log(); + console.log(` ${entries.length} estate(s): ${totals.identical} identical, ${totals.derived} identical once derived, ${totals.intended} intended, ` + + `${totals.behaviour} run side by side, ${totals.added} added, ${totals.removed} removed, ${totals.divergent} divergent step(s)`); + console.log(problemsTotal === 0 + ? ' every caller + db-migrations-pr.yml / db-drift-env.yml reproduces the workflow it replaces' + : ` ${problemsTotal} problem(s): a caller would not do what its workflow did before`); + process.exit(problemsTotal === 0 ? 0 : 1); +}; + // ───────────────────────────────────────────────────────────────────────────── const [first, ...rest] = process.argv.slice(2); if (!first) usage(); if (first === 'infra-diff') infraDiff(rest); +else if (first === 'db') databaseWorkflows(rest); else if (existsSync(first)) libraries(first); else usage(); diff --git a/test/cli/npm-check/package.json b/test/cli/npm-check/package.json new file mode 100644 index 0000000..e1a1d02 --- /dev/null +++ b/test/cli/npm-check/package.json @@ -0,0 +1,10 @@ +{ + "name": "npm-check-fixture", + "private": true, + "dependencies": { + "@beplus/configuration": "0.127.0" + }, + "devDependencies": { + "@beplus/fixture-local": "workspace:*" + } +}