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/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/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/.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..dd33260 --- /dev/null +++ b/.github/workflows/test-cli.yml @@ -0,0 +1,129 @@ +# 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. +# +# 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: + 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 + - 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 + + # 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 + + # 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 + 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 }} + 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: | + set -euo pipefail + + if [[ "$EXPECT" == failure ]]; then + if [[ "$OUTCOME" != failure ]]; then + echo "::error::The action did not fail — $CASE (outcome: $OUTCOME)." + exit 1 + fi + echo "The action failed, as it must: $CASE." + 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..6e0d6c0 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 ``` @@ -94,6 +96,120 @@ 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. + +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: @@ -138,9 +254,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..bcb8e6c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,15 +1,19 @@ # 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 +5. Optionally (`BE_NPM_CHECK`) proves the registry then serves real `@beplus` packages — see [The registry never says no](#the-registry-never-says-no) --- @@ -17,9 +21,55 @@ 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_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. | + +--- + +## 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. --- @@ -34,26 +84,60 @@ 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 -### 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 +148,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 +178,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 +196,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..91a1089 100644 --- a/cli/action.yml +++ b/cli/action.yml @@ -1,17 +1,39 @@ 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 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 + 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 +54,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'}} @@ -57,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 b7e2921..517c0ec 100755 --- a/scripts/verify-callers.mjs +++ b/scripts/verify-callers.mjs @@ -1,96 +1,1083 @@ #!/usr/bin/env node /** - * The R2-1 verification gate. - * --------------------------- + * 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: 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 + * + * 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 - * 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 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 { readFileSync } from 'node:fs'; -import { join } 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 ORG = process.argv[2]; -if (!ORG) { +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); -} +}; // `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}`); + }; + + /** + * 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'; + }; -const shared = Object.fromEntries( - ['library-ci', 'library-publish'].map((n) => [ - n, YAML.parse(readFileSync(join(HERE, '.github', 'workflows', `${n}.yml`), 'utf8')), - ]), -); + 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 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 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)) { + 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}`); -}; - -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( - execFileSync('git', ['-C', join(ORG, repo), 'show', `${process.env.BEFORE_REF ?? 'HEAD~1'}:${path}`], { encoding: 'utf8', maxBuffer: 1e8 }), - ); - 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 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); +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(` ${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); +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// 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/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/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:*" + } +} 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"] +}