From 808f3a2443231b369a8798242bbde95eb4d2bff6 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Tue, 6 Oct 2026 11:22:37 +0200 Subject: [PATCH 1/2] feat(cli): installs the beplus CLI the repository pins, via `be auto` (BE-213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI release must never change what a repository's CI does without a commit in that repository, and `cli` defaulted to `latest`. Now, with no `BE_CLI_VERSION`, it installs the version pinned in the nearest beplus.estate.json → cli.version by running `be auto` — the same command a laptop, a Docker build stage or a buildspec runs, so the pin is resolved in one place — and then fails unless `beplus --version` is exactly that pin. An inexact or unreadable pin fails the run; it never falls back to `latest`. An explicit `BE_CLI_VERSION` still wins (with a warning when it differs from the pin). With no pin the action keeps today's behaviour, `be latest`, and says with a `::warning::` that the repository floats. `BE_WORKING_DIRECTORY` says where the nearest manifest is looked up from (default: the workspace). `cdk-package` installs the CLI through `cli`; its `cli-version` default goes from `latest` to empty so it honours the pin too (its note about `latest` being v1 is stale: latest is 2.11.0). The four reusable workflows stop passing an explicit `latest`, which would override a pin; `vars.BE_CLI_VERSION` still wins when it is set. `test-cli.yml` runs this branch's action against fixtures in test/cli: a pin installs exactly it (2.10.0 — not the newest release, so a run that floated cannot pass), also from a package directory below the manifest; BE_CLI_VERSION wins; no pin and no manifest float on latest; an inexact pin (`2`) fails the run. The pinned legs go green once `be auto` reads beplus.estate.json on beplus/be@main. Co-Authored-By: Igor Lamos --- .github/workflows/docs-pages.yml | 2 +- .github/workflows/library-ci.yml | 2 +- .github/workflows/library-publish.yml | 4 +- .github/workflows/provisioning.yml | 2 +- .github/workflows/test-cli.yml | 113 +++++++++++++++++++++++++ README.md | 9 +- cdk-package/action.yml | 13 +-- cli/README.md | 83 +++++++++++++++--- cli/action.yml | 116 ++++++++++++++++++++++++-- test/cli/inexact/beplus.estate.json | 9 ++ test/cli/pinned/beplus.estate.json | 9 ++ test/cli/unpinned/beplus.estate.json | 8 ++ 12 files changed, 339 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/test-cli.yml create mode 100644 test/cli/inexact/beplus.estate.json create mode 100644 test/cli/pinned/beplus.estate.json create mode 100644 test/cli/unpinned/beplus.estate.json diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 04aeff6..a80424a 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -212,7 +212,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install dependencies diff --git a/.github/workflows/library-ci.yml b/.github/workflows/library-ci.yml index 7428a00..586b983 100644 --- a/.github/workflows/library-ci.yml +++ b/.github/workflows/library-ci.yml @@ -122,7 +122,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install the Dependencies diff --git a/.github/workflows/library-publish.yml b/.github/workflows/library-publish.yml index 242064d..cef1278 100644 --- a/.github/workflows/library-publish.yml +++ b/.github/workflows/library-publish.yml @@ -170,7 +170,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - name: Install the Dependencies @@ -611,7 +611,7 @@ jobs: # ~/.npmrc. Nothing here installs, so no npm.beplus.cloud token is needed. - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} - name: Authenticate with AWS CodeArtifact run: beplus aws codeartifact auth --env "${BE_ENVIRONMENT}" diff --git a/.github/workflows/provisioning.yml b/.github/workflows/provisioning.yml index d7be707..f03a53d 100644 --- a/.github/workflows/provisioning.yml +++ b/.github/workflows/provisioning.yml @@ -57,7 +57,7 @@ jobs: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} BE_NPM_TOKEN: ${{ secrets.BE_NPM_TOKEN }} with: - BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION || 'latest' }} + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} BE_NPM_AUTH: true - uses: beplus/setup-beplus/provisioning@v2 diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml new file mode 100644 index 0000000..f380483 --- /dev/null +++ b/.github/workflows/test-cli.yml @@ -0,0 +1,113 @@ +# The `cli` action installs the beplus CLI the repository pins. +# --------------------------------------------------------------- +# A CLI release must never change what a repository's CI does without a commit in +# that repository. So `cli` (and `cdk-package`, which calls it) installs the +# version pinned in the nearest beplus.estate.json → cli.version via `be auto`, +# and checks `beplus --version` against the pin before anything uses the CLI. +# +# Each leg runs THIS branch's action (`uses: ./cli`) on a fresh runner against a +# fixture under test/cli, and asserts what `beplus --version` reports. The pinned +# fixture pins 2.10.0 — not the newest release — so a run that floated on +# `be latest` instead of honouring the pin cannot pass by accident. +# +# `be` itself comes from beplus/be@main, so the pinned legs go green once +# `be auto` reads beplus.estate.json there. +name: Test cli + +on: + pull_request: + paths: + - cli/** + - cdk-package/** + - test/cli/** + - .github/workflows/test-cli.yml + push: + branches: [main] + paths: + - cli/** + - cdk-package/** + - test/cli/** + - .github/workflows/test-cli.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + pin: + name: ${{ matrix.case }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - case: a pin installs exactly the pinned version + dir: test/cli/pinned + expect: 2.10.0 + - case: the nearest manifest pins a package below it + dir: test/cli/pinned/packages/app + expect: 2.10.0 + - case: BE_CLI_VERSION wins over the pin + dir: test/cli/pinned + version: 2.9.0 + expect: 2.9.0 + - case: a manifest without a pin floats on latest + dir: test/cli/unpinned + expect: latest + - case: no manifest floats on latest + dir: "" + expect: latest + - case: an inexact pin fails the run + dir: test/cli/inexact + expect: failure + + steps: + - uses: actions/checkout@v4 + + # A package directory with no manifest of its own: `be auto` must walk up + # to test/cli/pinned, the way it does from packages/apps/* in an estate. + - name: Make a package directory below the pinned manifest + run: mkdir -p test/cli/pinned/packages/app + + - name: Setup beplus CLI + id: setup + uses: ./cli + continue-on-error: ${{ matrix.expect == 'failure' }} + with: + BE_CLI_VERSION: ${{ matrix.version }} + # Empty `dir` = a directory with no beplus.estate.json at or above it. + BE_WORKING_DIRECTORY: ${{ matrix.dir || runner.temp }} + + - name: Assert the installed version + shell: bash + env: + EXPECT: ${{ matrix.expect }} + OUTCOME: ${{ steps.setup.outcome }} + run: | + set -euo pipefail + + if [[ "$EXPECT" == failure ]]; then + if [[ "$OUTCOME" != failure ]]; then + echo "::error::The action accepted a pin that is not an exact version (outcome: $OUTCOME)." + exit 1 + fi + echo "The action refused the inexact pin, as it must." + exit 0 + fi + + if [[ "$EXPECT" == latest ]]; then + EXPECT="$(be --latest)" + EXPECT="${EXPECT#v}" + fi + + out="$(beplus --version)" + echo "$out" + installed="" + if [[ "$out" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?) ]]; then + installed="${BASH_REMATCH[1]}" + fi + if [[ "$installed" != "$EXPECT" ]]; then + echo "::error::beplus --version reports ${installed:-nothing}, expected $EXPECT." + exit 1 + fi + echo "beplus CLI $installed, as expected." diff --git a/README.md b/README.md index c200631..bb23cc7 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ Composite GitHub Action collection for setting up the beplus Environment in GitH ## Available Actions ### [`cli`](./cli/README.md) -Installs the beplus CLI (`@beplus/be` and `beplus`) with optional npm authentication. +Installs the beplus CLI (`@beplus/be` and `beplus`) with optional npm authentication — +the version the repository pins in `beplus.estate.json` → `cli.version` (`be auto`). +A repository without a pin gets `latest` and a warning that it floats; see +[Pinning the CLI](./cli/README.md#pinning-the-cli). ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: latest BE_NPM_AUTH: false ``` @@ -138,9 +140,8 @@ jobs: steps: - uses: actions/checkout@v4 + # The version pinned in beplus.estate.json → cli.version - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest - uses: beplus/setup-beplus/git@v2 diff --git a/cdk-package/action.yml b/cdk-package/action.yml index 05c1aa9..9dd0321 100644 --- a/cdk-package/action.yml +++ b/cdk-package/action.yml @@ -77,12 +77,13 @@ inputs: default: us-east-1 cli-version: description: >- - Which beplus CLI to install. ⚠️ `latest` resolves to the newest FULL - release, which today is v1 — and the resolve/prune steps below call - `beplus infra`, which only exists in v2. Callers on v2 must pass an - explicit version until a v2 release is tagged. + Which beplus CLI to install. Empty (the default) installs the version the + repository pins in beplus.estate.json → cli.version (`be auto`, via the + `cli` action); without a pin it floats on `latest`, with a warning. A value + here wins over the pin. The resolve/prune steps below call `beplus infra`, + which only exists in v2. required: false - default: latest + default: "" npm-token: description: BE_NPM_TOKEN. A composite action cannot read `secrets`, so the caller passes it. required: true @@ -113,6 +114,8 @@ runs: # @beplus/* lives on npm.beplus.cloud (routed by common/config/rush/.npmrc). # Without the token pnpm still gets HTTP 200 from the proxy — with a stub # body — and fails late and unhelpfully with ERR_PNPM_TARBALL_INTEGRITY. + # The same step installs the beplus CLI the repository pins (beplus.estate.json + # → cli.version, checked out above) unless `cli-version` overrides it. - name: Authenticate the @beplus scope uses: beplus/setup-beplus/cli@v2 env: diff --git a/cli/README.md b/cli/README.md index 470fe1d..9a87a56 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,14 +1,17 @@ # Setup beplus CLI (GitHub Action) -Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) and then installs the [**beplus CLI**](https://github.com/beplus/cli) (`beplus`) into a **writable prefix** (no `sudo` required). Optionally runs `beplus npm auth` for [beplus.cloud](https://beplus.cloud) package access. +Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) and then installs the [**beplus CLI**](https://github.com/beplus/cli) (`beplus`) into a **writable prefix** (no `sudo` required) — by default **the version the repository pins** in `beplus.estate.json`. Optionally runs `beplus npm auth` for [beplus.cloud](https://beplus.cloud) package access. --- ## What it does 1. Installs `be` using the official install script -2. Installs `beplus` via `be ` -3. Verifies `beplus --version` +2. Chooses the version: + - `BE_CLI_VERSION` set → `be ` (wins over a pin, with a warning when it differs from it) + - otherwise, the nearest `beplus.estate.json` has `cli.version` → `be auto`, which installs exactly that version + - otherwise → `be latest`, as before, with a `::warning::` that the repository floats +3. Verifies `beplus --version` — and, for a pinned run, fails unless it is exactly the pinned version 4. Optionally runs `beplus npm auth` if enabled and required environment variables are present --- @@ -17,9 +20,54 @@ Composite GitHub Action that installs [@beplus/be](https://github.com/beplus/be) | Input | Required | Default | Description | |------|----------|---------|-------------| -| `BE_CLI_VERSION` | No | `latest` | Version or channel of beplus CLI to install (passed to `be`) | +| `BE_CLI_VERSION` | No | empty | Version or channel of beplus CLI to install (passed to `be`). Empty installs the repository's pin (`be auto`), or `latest` with a warning when there is none. A value here wins over the pin. | | `BE_NPM_AUTH` | No | `false` | If `true`, runs `beplus npm auth` | | `BE_PREFIX` | No | empty | Install prefix for beplus binaries. If empty, defaults to `$HOME/.beplus`. Must be writable. | +| `BE_WORKING_DIRECTORY` | No | the workspace | Where the pin is looked up: the nearest `beplus.estate.json` at or above this directory, exactly as `be auto` finds it. Relative to the workspace. | + +--- + +## Pinning the CLI + +A CLI release must never change what a repository's CI does without a commit in +that repository. So the repository commits the version it runs, in +`beplus.estate.json` at its root: + +```json +{ + "cli": { "version": "2.11.0" } +} +``` + +`version` is an exact version (`2.11.0`, not `2` or `latest`). With it, this +action runs `be auto` — the same command a laptop, a Docker build stage or a +buildspec runs, so every place resolves the pin the same way — and then checks +`beplus --version` against it. An inexact or unreadable pin fails the run; it +never falls back to `latest`. + +Check the repository out **before** this action, or there is no manifest to read +(and the run floats, with a warning). In a monorepo the nearest manifest at or +above `BE_WORKING_DIRECTORY` wins. + +[`cdk-package`](../cdk-package/action.yml) installs the CLI through this action +with an empty `cli-version` by default, so it honours the pin too. +[`test-cli.yml`](../.github/workflows/test-cli.yml) proves all of this on every +change, against the fixtures in [`test/cli`](../test/cli). + +### Bumping the pin + +One commit per repository, nothing else in it: + +1. Change `cli.version` in `beplus.estate.json` (e.g. `2.11.0` → `2.12.0`). +2. Open a pull request. Its CI installs the new version through this action and + proves it with `beplus --version`; the deploy path (`be auto` in Dockerfiles, + base images, buildspecs) picks the same version up from the same file. +3. Merge it on its own — not together with an unrelated change — so a + behaviour change it brings is attributable to the bump. + +A repository without a pin keeps working — it installs `latest` — but every run +says so with a warning, because every CLI release changes its CI without a +commit there. --- @@ -38,22 +86,22 @@ Required only when `BE_NPM_AUTH` is set to `"true"`: ## Usage -### Minimal usage +### Minimal usage — the version the repository pins ```yaml +- uses: actions/checkout@v4 + - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest ``` --- -### Install a specific version +### Install a specific version (overrides the pin) ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: v1.0.0-beta.3 + BE_CLI_VERSION: 2.11.0 ``` --- @@ -64,7 +112,6 @@ Provide `BE_NPM_TARGET` and `BE_NPM_TOKEN` via GitHub secrets or variables. ```yaml - uses: beplus/setup-beplus/cli@v2 with: - BE_CLI_VERSION: latest BE_NPM_AUTH: "true" env: BE_NPM_TARGET: ${{ vars.BE_NPM_TARGET }} @@ -95,9 +142,8 @@ jobs: with: node-version: 24 + # Installs the version pinned in beplus.estate.json → cli.version - uses: beplus/setup-beplus/cli@v2 - with: - BE_CLI_VERSION: latest - run: | beplus --version @@ -114,6 +160,19 @@ If you override `BE_PREFIX`, ensure it points to a writable directory. --- +### "This repository does not pin the beplus CLI" + +The run found no `cli.version` in the nearest `beplus.estate.json` (or no +manifest at all — is the repository checked out before this action?) and +installed `latest`. Add the pin; see [Pinning the CLI](#pinning-the-cli). + +### "pins beplus CLI X, but `be auto` installed Y" + +The pin is not an exact version, or `be` resolved it differently from the file +this action read. The run stops rather than continue on a CLI nobody chose. + +--- + ### `BE_NPM_AUTH` appears to do nothing This is expected unless: diff --git a/cli/action.yml b/cli/action.yml index 6cb7e5b..42f5680 100644 --- a/cli/action.yml +++ b/cli/action.yml @@ -1,10 +1,14 @@ name: "Setup beplus CLI" -description: "Install and verify beplus CLI + authenticate npm for beplus.cloud packages" +description: "Install and verify the beplus CLI the repository pins (beplus.estate.json → cli.version) + authenticate npm for beplus.cloud packages" inputs: BE_CLI_VERSION: - description: "Version of beplus CLI to install (default: latest)" + description: >- + Version of beplus CLI to install, passed to `be` (e.g. 2.11.0, 2, latest). + Empty (the default) installs the version the repository pins in the nearest + beplus.estate.json → cli.version, via `be auto`; a repository without a pin + gets `latest`, with a warning that it floats. A value here wins over the pin. required: false - default: "latest" + default: "" BE_NPM_AUTH: description: "Authenticate beplus.cloud NPM? (requires BE_NPM_TARGET and BE_NPM_TOKEN env vars)" required: false @@ -12,6 +16,13 @@ inputs: BE_PREFIX: description: "Install prefix for beplus binaries (must be writable)." required: false + BE_WORKING_DIRECTORY: + description: >- + Where the pin is looked up: the nearest beplus.estate.json at or above this + directory, exactly as `be auto` finds it. Relative to the workspace. + Default: the workspace (check the repository out first). + required: false + default: "" runs: using: "composite" @@ -32,18 +43,113 @@ runs: echo "BE_PREFIX=$BE_PREFIX" >> "$GITHUB_ENV" echo "$BE_PREFIX/bin" >> "$GITHUB_PATH" + # Which CLI this run gets. A CLI release must never change what a repository's + # CI does without a commit in that repository, so the version comes from the + # pin the repository commits — not from whatever was released this morning. + # + # This step only decides WHETHER there is a pin. Resolving and validating it + # is `be auto`'s job (one implementation, the same one a laptop and a Docker + # build run); the pin read here is kept to prove, after the install, that + # `be auto` installed exactly it. + - name: Choose the beplus CLI version + id: choose + shell: bash + working-directory: ${{ inputs.BE_WORKING_DIRECTORY || github.workspace }} + env: + INPUT_VERSION: ${{ inputs.BE_CLI_VERSION }} + run: | + set -euo pipefail + + # The nearest beplus.estate.json at or above this directory — the same + # walk `be auto` does. + manifest="" + dir="$PWD" + while :; do + if [[ -e "$dir/beplus.estate.json" ]]; then + manifest="$dir/beplus.estate.json" + break + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + # Annotations point at the file relative to the repository when they can. + file="${manifest#"$GITHUB_WORKSPACE"/}" + + pin="" + if [[ -n "$manifest" ]]; then + command -v jq > /dev/null || { echo "::error::jq is required to read the beplus CLI pin from $manifest."; exit 1; } + # A manifest that cannot be read is not "no pin": fail, never float. + if ! pin="$(jq -r '.cli.version // empty' "$manifest")"; then + echo "::error file=$file::Cannot read cli.version from $manifest (invalid JSON, or \"cli\" is not an object)." + exit 1 + fi + if [[ "$pin" =~ [[:space:]] ]]; then + echo "::error file=$file::cli.version in $manifest is not a version: \"$pin\"." + exit 1 + fi + fi + + version="$INPUT_VERSION" + floating="" + if [[ -n "$INPUT_VERSION" ]]; then + if [[ -n "$pin" && "${INPUT_VERSION#v}" != "${pin#v}" ]]; then + echo "::warning file=$file::BE_CLI_VERSION=$INPUT_VERSION overrides the beplus CLI $pin pinned in $manifest." + fi + pin="" + echo "Installing beplus CLI $INPUT_VERSION (BE_CLI_VERSION)." + elif [[ -n "$pin" ]]; then + version="auto" + echo "Installing beplus CLI $pin, pinned in $manifest (be auto)." + else + # Today's behaviour, said out loud. + version="latest" + floating="${manifest:-no beplus.estate.json at or above $PWD}" + echo "No beplus CLI pin ($floating): installing latest." + fi + + { + echo "version=$version" + echo "pin=$pin" + echo "manifest=$manifest" + echo "floating=$floating" + } >> "$GITHUB_OUTPUT" + - name: Install beplus CLI shell: bash + working-directory: ${{ inputs.BE_WORKING_DIRECTORY || github.workspace }} + env: + VERSION: ${{ steps.choose.outputs.version }} run: | set -euo pipefail - be ${{ inputs.BE_CLI_VERSION }} + be "$VERSION" - name: Verify beplus CLI version shell: bash + env: + PIN: ${{ steps.choose.outputs.pin }} + MANIFEST: ${{ steps.choose.outputs.manifest }} + FLOATING: ${{ steps.choose.outputs.floating }} run: | set -euo pipefail + out="$(beplus --version)" echo "beplus CLI version:" - beplus --version + echo "$out" + + installed="" + if [[ "$out" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?) ]]; then + installed="${BASH_REMATCH[1]}" + fi + file="${MANIFEST#"$GITHUB_WORKSPACE"/}" + + # A pinned run installs the pin or fails — never "something close to it". + if [[ -n "$PIN" && "$installed" != "${PIN#v}" ]]; then + echo "::error file=$file::$MANIFEST pins beplus CLI $PIN, but \`be auto\` installed ${installed:-an unknown version}." + exit 1 + fi + + if [[ -n "$FLOATING" ]]; then + echo "::warning${MANIFEST:+ file=$file}::This repository does not pin the beplus CLI ($FLOATING), so its CI floats on \`be latest\` — every CLI release changes it without a commit here. Pin it: add \"cli\": { \"version\": \"${installed:-x.y.z}\" } to beplus.estate.json." + fi - name: beplus npm auth if: ${{ inputs.BE_NPM_AUTH == 'true'}} diff --git a/test/cli/inexact/beplus.estate.json b/test/cli/inexact/beplus.estate.json new file mode 100644 index 0000000..16cec19 --- /dev/null +++ b/test/cli/inexact/beplus.estate.json @@ -0,0 +1,9 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: a pin that is not an exact", + "version. `2` would float across every 2.x release, so it must fail the run,", + "never install the newest 2.x." + ], + "environments": ["dev"], + "cli": { "version": "2" } +} diff --git a/test/cli/pinned/beplus.estate.json b/test/cli/pinned/beplus.estate.json new file mode 100644 index 0000000..acfba51 --- /dev/null +++ b/test/cli/pinned/beplus.estate.json @@ -0,0 +1,9 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: a repository that pins its beplus CLI.", + "2.10.0 on purpose, NOT the newest release: a run that floated on `be latest`", + "instead of honouring the pin cannot pass by accident." + ], + "environments": ["dev"], + "cli": { "version": "2.10.0" } +} diff --git a/test/cli/unpinned/beplus.estate.json b/test/cli/unpinned/beplus.estate.json new file mode 100644 index 0000000..26dc72a --- /dev/null +++ b/test/cli/unpinned/beplus.estate.json @@ -0,0 +1,8 @@ +{ + "$comment": [ + "Fixture for .github/workflows/test-cli.yml: an estate manifest with no `cli`", + "section. The action keeps today's behaviour (`be latest`) and warns that the", + "repository floats." + ], + "environments": ["dev"] +} From cd3281038c6029b3033c3353d0573ef6e272f451 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Tue, 6 Oct 2026 11:48:53 +0200 Subject: [PATCH 2/2] feat(infra-diff): the estates' pull-request cdk diff, as one reusable workflow (BE-218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bepluscloud/ai, ironmountain-cz/monorepo and mrkithq/monorepo each carried a 186-line infra-diff.yml. Their jobs differed in three values, and all three are derived now, so the workflow has no inputs: - the role github-actions----cdk-diff, from the calling repository (github.repository_owner + github.event.repository.name), as each estate's GitHubOIDCStack names it; the account is still vars.BE_AWS_ACCOUNT_ID of the environment; - the region, from beplus.estate.json defaultRegion (vars.AWS_REGION still wins); - the sticky-comment marker, from naming.product lower-cased: be / mountaineer / mrkit, exactly the markers the copies used, so the switch edits a PR's existing comment. A plan step reads the last two from the manifest, validates them, and hands the marker to the comment script through env, never pasted into it. Every other step body is byte-identical to the copies, bar the CLI version: `vars.BE_CLI_VERSION || '2'` floated on the newest 2.x; empty now installs the repository's pin through `be auto` (BE-213). It cannot deploy: the composite always runs `mode: diff` as the read-only …-cdk-diff role, both literals, and with no input declared a caller that passes `mode` is refused by GitHub. The trigger paths, the permissions and the concurrency group stay in each repository's ~30-line caller. scripts/verify-callers.mjs gains `infra-diff`: it compares the shared jobs, resolved for an estate, with the file its caller replaced — every job key, every step, every run/script byte — allowing only declared differences (three derivations, the BE-213 pin, the added manifest step/outputs/env), and checks the shared workflow can only diff. The three callers against bepluscloud origin/dev, Mountaineer origin/dev and MRKIT dev: 30 steps, 18 identical, 3 identical once derived, 6 the intended pin, 3 added, 0 divergent. Nine mutations (deploy mode, deploy role, a mode input, an edited or dropped step, a caller passing mode, a lost path, an extra job, the wrong repository) all go red. The R2-1 library mode is unchanged. Co-Authored-By: Igor Lamos --- .github/workflows/infra-diff.yml | 260 ++++++++++++++++ README.md | 37 +++ scripts/verify-callers.mjs | 491 ++++++++++++++++++++++++++----- 3 files changed, 721 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/infra-diff.yml diff --git a/.github/workflows/infra-diff.yml b/.github/workflows/infra-diff.yml new file mode 100644 index 0000000..829e71d --- /dev/null +++ b/.github/workflows/infra-diff.yml @@ -0,0 +1,260 @@ +# What a pull request would do to an estate, before it does it — once. +# -------------------------------------------------------------------- +# bepluscloud/ai, ironmountain-cz/monorepo and mrkithq/monorepo each carried a +# 186-line copy of this file. Their jobs differed in exactly three values, and +# every one of them is already written down somewhere this workflow can read: +# +# the role github-actions----cdk-diff +# → from the calling repository (`github.repository_owner`, +# `github.event.repository.name`), the way each estate's +# packages/aws/main GitHubOIDCStack names it +# the region us-east-1 / eu-central-1 +# → beplus.estate.json `defaultRegion` (`vars.AWS_REGION` +# still wins, as it did) +# the marker +# → beplus.estate.json `naming.product`, lower-cased, so the +# switch edits a pull request's existing comment instead of +# adding a second one +# +# so there are no inputs. Everything else — every step body — is byte-identical +# to the copies it replaces, bar the CLI version: `vars.BE_CLI_VERSION || '2'` +# floated on the newest 2.x; empty now installs the repository's pin +# (beplus.estate.json → cli.version, `be auto`; BE-213), and the variable still +# wins when it is set. `scripts/verify-callers.mjs infra-diff` proves it. +# +# What stays in the repository is what is the repository's: when it runs (the +# paths), the permissions it grants, and the concurrency group. +# +# name: infra diff +# on: +# pull_request: +# paths: ["packages/aws/**", "beplus.estate.json", ".github/workflows/infra-diff.yml", …] +# permissions: +# id-token: write +# contents: read +# pull-requests: write +# concurrency: +# group: infra-diff-${{ github.event.pull_request.number || github.ref }} +# cancel-in-progress: true +# jobs: +# infra-diff: +# uses: beplus/setup-beplus/.github/workflows/infra-diff.yml@v2 +# secrets: inherit +# +# ⚠️ THIS WORKFLOW CANNOT DEPLOY ANYTHING, AND NOT BECAUSE IT PROMISES NOT TO. +# It assumes `…--cdk-diff`, whose only `sts:AssumeRole` grant is the CDK +# `lookup` role — read-only by construction. The `deploy` / `file-publishing` / +# `image-publishing` roles that `cdk deploy` needs are not reachable from it, +# and `…--cdk-deploy`, which can reach them, does not trust +# `pull_request` at all. Both are IAM, in the account, deployed from each +# estate's `packages/aws/main`. +# +# And there is nothing here a caller can turn: no input reaches the composite. +# `mode: diff` and the `-cdk-diff` suffix are literals, and a caller that passes +# `mode` (or anything else) under `with:` is refused by GitHub before a job +# starts, because no input is declared. Keep it that way — an input that could +# select the mode or the role is exactly the switch the role split exists to +# make useless. `beplus infra lint` still checks the caller's own jobs. +# +# See cli_v2 EXTRACTION-PLAN.md, slice R2-3, and Linear BE-218. + +name: infra diff + +on: + workflow_call: + secrets: + BE_NPM_TOKEN: + description: >- + npm.beplus.cloud token for the @beplus scope. An environment secret in + the estates — the diff job claims the environment, which is how it + reaches it — so `secrets: inherit` (or nothing) is all a caller passes. + required: false + +# The caller must grant all three: a called workflow can only narrow these. +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + # The matrix comes from beplus.estate.json so that the + # list of CDK apps lives in one file rather than in three. + plan: + name: plan + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + packages: ${{ steps.plan.outputs.packages }} + region: ${{ steps.estate.outputs.region }} + marker: ${{ steps.estate.outputs.marker }} + steps: + - uses: actions/checkout@v4 + # `beplus infra` reads beplus.estate.json and nothing + # else — no AWS, no network. The install is the price of the five .mjs + # programs this plan job used to carry a copy of; see cli_v2 + # EXTRACTION-PLAN.md, slice R1-2. + - name: Install the beplus CLI + uses: beplus/setup-beplus/cli@v2 + with: + BE_CLI_VERSION: ${{ vars.BE_CLI_VERSION }} + + # ⚠️ THE CHECK THAT NO PULL-REQUEST PATH CAN DEPLOY. It reads the calling + # repository's own workflows against its own manifest, and it belongs on a + # PR — where a reviewer sees it go red — not only in an npm script nobody + # runs. + - name: Check the workflows against the manifest + run: beplus infra lint + + - id: plan + env: + MODE: diff + BASE_REF: ${{ github.base_ref }} + run: beplus infra matrix --githubOutput >> "$GITHUB_OUTPUT" + + # The two values each estate's copy of this file wrote out by hand, read + # from the manifest that already holds them (the CLI resolves the same + # file, in the same order, with the same us-east-1 default). + # + # Both come from the pull request's own tree, so both are treated as + # data: checked here, and handed to the comment script through `env`, + # never pasted into it. + - name: Read the region and the comment marker from the manifest + id: estate + run: | + set -euo pipefail + manifest=beplus.estate.json + [ -f "$manifest" ] || manifest=.github/actions/cdk-package/packages.json + + # ⚠️ NOT us-east-1 EVERYWHERE: Mountaineer runs in eu-central-1, and + # a wrong region addresses an account with nothing in it. + region="$(jq -r '.defaultRegion // "us-east-1"' "$manifest")" + if ! [[ "$region" =~ ^[a-z]{2}(-[a-z]+)+-[0-9]+$ ]]; then + echo "::error file=$manifest::defaultRegion \"$region\" is not an AWS region." + exit 1 + fi + + # be, mountaineer, mrkit — the markers the three copies used. + product="$(jq -r '.naming.product // empty' "$manifest")" + marker="$(printf '%s' "${product:-${GITHUB_REPOSITORY#*/}}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._-' '-')" + + echo "region=$region" >> "$GITHUB_OUTPUT" + echo "marker=$marker" >> "$GITHUB_OUTPUT" + + diff: + name: ${{ matrix.package }} · ${{ matrix.env }} + needs: plan + runs-on: ubuntu-latest + # Claiming the environment is how the job reaches BE_NPM_TOKEN, which is an + # environment secret. On GitHub Free it adds no gate; with Environment + # protection rules it would make every diff wait for a reviewer, which is + # why the reviewers go on `infra.yml`'s jobs and not on these. + environment: ${{ matrix.env }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + # Kept from the estates' copies, where the composite was a LOCAL action + # and the runner could not find its action.yml without a checkout. The + # shared composite checks the repository out itself; this one stays so + # the steps are exactly the ones the copies ran. + - uses: actions/checkout@v4 + - uses: beplus/setup-beplus/cdk-package@v2 + with: + cli-version: ${{ vars.BE_CLI_VERSION }} + package: ${{ matrix.package }} + env: ${{ matrix.env }} + mode: diff + role-arn: arn:aws:iam::${{ vars.BE_AWS_ACCOUNT_ID }}:role/github-actions-${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ matrix.env }}-cdk-diff + aws-region: ${{ vars.AWS_REGION || needs.plan.outputs.region }} + npm-token: ${{ secrets.BE_NPM_TOKEN }} + npm-target: ${{ vars.BE_NPM_TARGET }} + + # One comment per PACKAGE, not per matrix leg: `main` deploys to every + # account, and one comment per account about one package is how a reviewer + # learns to scroll past all of them. + comment: + name: comment + needs: [plan, diff] + if: always() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: diffs + pattern: cdk-diff-* + merge-multiple: false + + - uses: actions/github-script@v7 + env: + PACKAGES: ${{ needs.plan.outputs.packages }} + DIFF_JOB_RESULT: ${{ needs.diff.result }} + MARKER: ${{ needs.plan.outputs.marker }} + with: + script: | + const { readFileSync, readdirSync, existsSync } = require("node:fs"); + const { join } = require("node:path"); + + // Sticky by an HTML marker rather than by a bot name: the same + // comment is edited on every push, so a 40-commit PR has six + // comments and not two hundred and forty. + const marker = (pkg) => ``; + const MAX = 55000; // GitHub rejects a comment body over 65536. + + const packages = JSON.parse(process.env.PACKAGES || "[]"); + const root = "diffs"; + const existing = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: context.issue.number, per_page: 100, + }); + + for (const pkg of packages) { + // One artifact per (package, environment): cdk-diff--. + const dirs = existsSync(root) + ? readdirSync(root).filter((d) => d.startsWith(`cdk-diff-${pkg}-`)) + : []; + + const sections = []; + for (const dir of dirs.sort()) { + const env = dir.replace(`cdk-diff-${pkg}-`, ""); + const files = readdirSync(join(root, dir)); + const body = files + .filter((f) => f.endsWith(".diff.txt")) + .map((f) => readFileSync(join(root, dir, f), "utf8")) + .join("\n") || "_no output captured_"; + const nag = files.find((f) => f.startsWith("cdk-nag-")); + sections.push( + `` + + `${env}\n\n\`\`\`diff\n${body.trim()}\n\`\`\`\n` + + (nag ? `\n**cdk-nag**\n\n${readFileSync(join(root, dir, nag), "utf8")}\n` : "") + + ``, + ); + } + + let text = [ + marker(pkg), + `### \`packages/aws/${pkg}\` — cdk diff`, + "", + sections.length ? sections.join("\n\n") : "_no diff was produced for this package_", + "", + `${process.env.DIFF_JOB_RESULT === "success" + ? "All diff jobs succeeded." + : `⚠️ The diff matrix finished \`${process.env.DIFF_JOB_RESULT}\` — a section may be missing rather than empty.` + } Full output is in the run's artifacts.`, + ].join("\n"); + + if (text.length > MAX) { + text = text.slice(0, MAX) + + "\n```\n\nTruncated. The whole diff is in this run's artifacts."; + } + + const previous = existing.find((c) => c.body?.includes(marker(pkg))); + if (previous) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body: text }); + } else { + await github.rest.issues.createComment({ + ...context.repo, issue_number: context.issue.number, body: text, + }); + } + } diff --git a/README.md b/README.md index bb23cc7..abbba20 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,43 @@ and stage and prod publish those same bytes — never a build of their own: They install nothing, so they need no `BE_NPM_TOKEN`. +An estate's pull-request `cdk diff` — one sticky comment per CDK app — is one more. +The three estates carried a 186-line copy each; the role, the region and the +comment marker were the only differences, and all three are derived (the role +`github-actions----cdk-diff` from the calling repository, the +region from `beplus.estate.json` → `defaultRegion`, the marker from `naming.product`), +so it has no inputs. The trigger, the permissions and the concurrency group stay +in the repository: + +```yaml +# .github/workflows/infra-diff.yml +name: infra diff +on: + pull_request: + paths: + - "packages/aws/**" + - "packages/apps//.bepluscloud/**" + - ".github/workflows/infra-diff.yml" + - "beplus.estate.json" +permissions: + id-token: write + contents: read + pull-requests: write +concurrency: + group: infra-diff-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + infra-diff: + uses: beplus/setup-beplus/.github/workflows/infra-diff.yml@v2 + secrets: inherit +``` + +It cannot deploy: the composite always runs in `mode: diff` as the read-only +`…-cdk-diff` role, and nothing a caller passes can change either. It installs the +CLI the repository pins (`cli.version`). `node scripts/verify-callers.mjs infra-diff +--repo ` proves a caller plus this workflow reproduce the file it replaced, +step for step. + A repository's docs site — `@beplus/docs-site` from beplus/docs — is built and published to GitHub Pages by one more: diff --git a/scripts/verify-callers.mjs b/scripts/verify-callers.mjs index b7e2921..4e5a174 100755 --- a/scripts/verify-callers.mjs +++ b/scripts/verify-callers.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * The R2-1 verification gate. - * --------------------------- + * The caller verification gate (R2-1, R2-3). + * ------------------------------------------ * A reusable workflow cannot be run locally, so this proves the next best thing: * that resolving the shared workflow WITH EACH CALLER'S INPUTS yields the same * enabled steps, in the same order, as the file that caller replaced. @@ -9,88 +9,445 @@ * The originals come from git — `HEAD~1` by default, or $BEFORE_REF — so this * keeps working as a regression check across the commit that matters. * - * Usage: node scripts/verify-callers.mjs - * e.g. node scripts/verify-callers.mjs ~/Git/github.com/beplus + * Usage: + * + * node scripts/verify-callers.mjs + * The thirteen library repositories' ci.yml / publish.yml callers of + * library-ci.yml / library-publish.yml (R2-1). + * e.g. node scripts/verify-callers.mjs ~/Git/github.com/beplus + * + * node scripts/verify-callers.mjs infra-diff --repo [--ref ] + * [--caller ] [--repository ] [--repo …] + * Each estate's `.github/workflows/infra-diff.yml` caller of infra-diff.yml + * (R2-3, BE-218). `--ref` is the commit whose infra-diff.yml the caller + * replaced (default $BEFORE_REF, else HEAD~1). `--caller` checks a caller + * that is not committed yet; the manifest is then read at `--ref` too, + * otherwise from the checkout. `--repository` defaults to the `origin` remote. + * e.g. node scripts/verify-callers.mjs infra-diff \ + * --repo ~/Git/github.com/bepluscloud/ai --ref origin/dev --caller /tmp/bepluscloud.yml * * What it CANNOT prove: that the shared workflow runs green on GitHub. That * needs setup-beplus pushed and tagged, and one repository taken through a real - * publish. Do that with beplus/docs before the other eleven. + * run. For the libraries do that with beplus/docs before the other eleven; for + * infra-diff, with one real pull request per estate, bepluscloud first. */ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; -const ORG = process.argv[2]; -if (!ORG) { +const HERE = new URL('..', import.meta.url).pathname; + +const usage = () => { console.error('usage: node scripts/verify-callers.mjs '); + console.error(' node scripts/verify-callers.mjs infra-diff --repo [--ref ] [--caller ] [--repository ] [--repo …]'); process.exit(2); -} +}; // `yaml` is not a dependency of this repo; borrow the one Rush already installed // in whichever estate is checked out beside it. -const require_ = createRequire(import.meta.url); -let YAML; -for (const candidate of [ - join(ORG, '..', 'mrkithq', 'monorepo', 'packages', 'aws', 'main', 'node_modules', 'yaml'), - join(ORG, '..', 'bepluscloud', 'ai', 'packages', 'aws', 'main', 'node_modules', 'yaml'), -]) { - try { YAML = require_(candidate); break; } catch { /* try the next */ } -} -if (!YAML) { console.error('no `yaml` module found next to this checkout'); process.exit(2); } - -const REPOS = ['ai', 'analytics', 'api', 'auth', 'cdk', 'cms', 'data', 'docs', 'mobile', 'saas', 'server', 'web']; -const HERE = new URL('..', import.meta.url).pathname; +const loadYaml = (roots) => { + const require_ = createRequire(import.meta.url); + for (const root of roots) { + try { return require_(join(root, 'packages', 'aws', 'main', 'node_modules', 'yaml')); } catch { /* try the next */ } + } + console.error('no `yaml` module found next to this checkout'); + process.exit(2); +}; + +const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', maxBuffer: 1e8 }); + +/** JSON with sorted keys: two parsed YAML values are equal iff this is. */ +const canon = (value) => JSON.stringify(value, (_key, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) + : v); + +// ═════════════════════════════════════════════════════════════════════════════ +// R2-1 — the library repositories +// ═════════════════════════════════════════════════════════════════════════════ + +const libraries = (ORG) => { + const YAML = loadYaml([ + join(ORG, '..', 'mrkithq', 'monorepo'), + join(ORG, '..', 'bepluscloud', 'ai'), + ]); + + const REPOS = ['ai', 'analytics', 'api', 'auth', 'cdk', 'cms', 'data', 'docs', 'mobile', 'saas', 'server', 'web']; + + const shared = Object.fromEntries( + ['library-ci', 'library-publish'].map((n) => [ + n, YAML.parse(readFileSync(join(HERE, '.github', 'workflows', `${n}.yml`), 'utf8')), + ]), + ); + + /** + * The steps a caller's inputs actually enable, in order. + * + * ⚠️ EVERY `inputs.x` IN THE CONDITION COUNTS, not just the first. The gates are + * compound — `inputs.force && inputs.force-change-files` — and reading only the + * leading one reported six repositories as gaining a step that is in fact + * skipped. A step is enabled unless SOME input it names is literally `false`; + * anything else leaves it in, with its own runtime condition intact. + */ + const enabledSteps = (workflow, withInputs) => { + const job = Object.values(workflow.jobs)[0]; + return job.steps + .filter((step) => { + const named = [...String(step.if ?? '').matchAll(/inputs\.([a-z-]+)/g)].map((m) => m[1]); + return !named.some((name) => withInputs[name] === false); + }) + .map((step) => step.name ?? `uses:${step.uses}`); + }; + + let failures = 0; + for (const repo of REPOS) { + for (const [file, workflow] of [['ci', 'library-ci'], ['publish', 'library-publish']]) { + const path = `.github/workflows/${file}.yml`; + const before = YAML.parse(git(join(ORG, repo), 'show', `${process.env.BEFORE_REF ?? 'HEAD~1'}:${path}`)); + const caller = YAML.parse(readFileSync(join(ORG, repo, path), 'utf8')); -const shared = Object.fromEntries( - ['library-ci', 'library-publish'].map((n) => [ - n, YAML.parse(readFileSync(join(HERE, '.github', 'workflows', `${n}.yml`), 'utf8')), - ]), -); + const wanted = Object.values(before.jobs)[0].steps.map((s) => s.name ?? `uses:${s.uses}`); + const got = enabledSteps(shared[workflow], Object.values(caller.jobs)[0].with ?? {}); + + if (JSON.stringify(wanted) === JSON.stringify(got)) { + console.log(` ✓ ${repo}/${file} ${got.length} steps`); + } else { + failures += 1; + console.log(` ✗ ${repo}/${file}`); + console.log(` had: ${wanted.join(' | ')}`); + console.log(` gets: ${got.join(' | ')}`); + } + } + } + console.log(); + console.log(failures === 0 + ? ` all ${REPOS.length * 2} workflows resolve to the steps they replace` + : ` ${failures} workflow(s) would not do what they did before`); + process.exit(failures === 0 ? 0 : 1); +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// R2-3 — infra-diff.yml in the three estates (BE-218) +// ═════════════════════════════════════════════════════════════════════════════ +// +// Stricter than the library check, because there are no inputs to resolve: the +// shared jobs are compared to the replaced file's jobs WHOLE — every job key, +// every step, every `run` / `script` byte — and the only differences allowed +// are the ones declared below, each with its reason. Anything else is divergent. + +const WORKFLOW = join('.github', 'workflows', 'infra-diff.yml'); +const SHARED_CALL = /^beplus\/setup-beplus\/\.github\/workflows\/infra-diff\.yml@\S+$/; /** - * The steps a caller's inputs actually enable, in order. - * - * ⚠️ EVERY `inputs.x` IN THE CONDITION COUNTS, not just the first. The gates are - * compound — `inputs.force && inputs.force-change-files` — and reading only the - * leading one reported six repositories as gaining a step that is in fact - * skipped. A step is enabled unless SOME input it names is literally `false`; - * anything else leaves it in, with its own runtime condition intact. + * What the shared workflow DERIVES where each copy wrote a literal. Applied to + * the shared side with the estate's own values; afterwards the text must be the + * copy's, byte for byte. */ -const enabledSteps = (workflow, withInputs) => { - const job = Object.values(workflow.jobs)[0]; - return job.steps - .filter((step) => { - const named = [...String(step.if ?? '').matchAll(/inputs\.([a-z-]+)/g)].map((m) => m[1]); - return !named.some((name) => withInputs[name] === false); - }) - .map((step) => step.name ?? `uses:${step.uses}`); +const DERIVED = [ + { + what: 'role ARN: - from the calling repository', + shared: '${{ github.repository_owner }}-${{ github.event.repository.name }}', + original: (e) => `${e.owner}-${e.name}`, + }, + { + what: 'region default: beplus.estate.json defaultRegion', + shared: '${{ vars.AWS_REGION || needs.plan.outputs.region }}', + original: (e) => `\${{ vars.AWS_REGION || '${e.region}' }}`, + }, + { + what: 'comment marker: beplus.estate.json naming.product, lower-cased', + shared: ' (${manifest.path})`); + + // ── The caller: what stays in the repository, exactly as it was ────────── + const KEPT = ['name', 'on', 'permissions', 'concurrency']; + for (const key of Object.keys(caller)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`caller: top-level \`${key}\` — nothing but ${KEPT.join(', ')} and the call belongs here`); } + for (const key of Object.keys(original)) { + if (![...KEPT, 'jobs'].includes(key)) problems.push(`original: top-level \`${key}\` has no home in caller or shared workflow`); + } + for (const key of KEPT) { + if (canon(caller[key]) !== canon(original[key])) { + problems.push(`caller: \`${key}\` differs from the original\n had: ${canon(original[key])}\n gets: ${canon(caller[key])}`); + } + } + // The jobs run with what the original granted; a called workflow can only narrow it. + if (canon(shared.permissions) !== canon(original.permissions)) { + problems.push(`shared: permissions ${canon(shared.permissions)} ≠ the original's ${canon(original.permissions)}`); + } + const callerJobs = Object.entries(caller.jobs ?? {}); + if (callerJobs.length !== 1) problems.push(`caller: ${callerJobs.length} jobs — exactly one, the call, belongs here`); + for (const [jobName, job] of callerJobs) { + if (!SHARED_CALL.test(String(job.uses ?? ''))) problems.push(`caller: job "${jobName}" does not call beplus/setup-beplus/.github/workflows/infra-diff.yml`); + for (const key of Object.keys(job.with ?? {})) { + if (!declaredInputs.includes(key)) problems.push(`caller: job "${jobName}" passes \`${key}\`, which the shared workflow does not declare — GitHub refuses the run`); + } + if (job.secrets !== 'inherit' && job.secrets !== undefined) problems.push(`caller: job "${jobName}" passes secrets explicitly — use \`secrets: inherit\``); + for (const key of Object.keys(job)) { + if (!['uses', 'with', 'secrets', 'name'].includes(key)) problems.push(`caller: job "${jobName}" sets \`${key}\` — the shared jobs own it`); + } + } + if (problems.length === 0) { + console.log(` ✓ caller ${KEPT.join(', ')} identical to the original; one job, ${callerJobs[0][1].uses}, secrets: ${callerJobs[0][1].secrets}`); + } + + // ── The jobs: the shared workflow, resolved for this estate, vs the copy ─ + const derivedPairs = DERIVED.map((d) => [d.shared, d.original(estate)]); + const intendedPairs = INTENDED.map((d) => [d.original, d.shared]); + const derivedHits = DERIVED.map(() => 0); + const intendedHits = INTENDED.map(() => 0); + + const sharedJobs = Object.keys(shared.jobs ?? {}); + const originalJobs = Object.keys(original.jobs ?? {}); + if (canon(sharedJobs) !== canon(originalJobs)) { + problems.push(`jobs: the shared workflow has [${sharedJobs}], the original had [${originalJobs}]`); + } + + for (const jobName of originalJobs.filter((j) => sharedJobs.includes(j))) { + const added = ADDED[jobName] ?? {}; + const { steps: sharedSteps = [], ...sharedJob } = shared.jobs[jobName]; + const { steps: originalSteps = [], ...originalJob } = original.jobs[jobName]; + + // Job keys. + const resolvedJob = substitute(sharedJob, derivedPairs, derivedHits); + for (const output of added.outputs ?? []) { + if (resolvedJob.outputs?.[output] === undefined) problems.push(`job "${jobName}": declared added output "${output}" is gone`); + else delete resolvedJob.outputs[output]; + } + if (canon(resolvedJob) !== canon(substitute(originalJob, intendedPairs, intendedHits))) { + problems.push(`job "${jobName}": job keys differ\n had: ${canon(originalJob)}\n gets: ${canon(resolvedJob)}`); + } + const extra = (added.outputs ?? []).length ? ` (+ outputs ${added.outputs.join(', ')})` : ''; + console.log(` job ${jobName}${extra}`); + + // Steps, in order. A declared added step is reported where it sits and + // skipped; every other shared step is paired with the original's next one. + const isAdded = (s) => (added.steps ?? []).includes(s.id); + for (const key of added.steps ?? []) { + if (!sharedSteps.some((s) => s.id === key)) problems.push(`job "${jobName}": declared added step "${key}" is gone`); + } + const pairs = []; + let next = 0; + for (const step of sharedSteps) { + if (isAdded(step)) pairs.push([step, null, true]); + else pairs.push([step, originalSteps[next++], false]); + } + for (; next < originalSteps.length; next += 1) pairs.push([undefined, originalSteps[next], false]); + for (const [i, [mine, theirs, isNew]] of pairs.entries()) { + const label = stepKey(mine ?? theirs).padEnd(58); + if (isNew) { + counts.added += 1; + console.log(` + ${label} added — reads the derived values from the manifest`); + continue; + } + if (!mine || !theirs) { + counts.divergent += 1; + problems.push(`job "${jobName}" step ${i + 1}: ${mine ? 'only in the shared workflow' : 'missing from the shared workflow'} — ${stepKey(mine ?? theirs)}`); + console.log(` ✗ ${label} ${mine ? 'only in the shared workflow' : 'MISSING from the shared workflow'}`); + continue; + } + if (canon(mine) === canon(theirs)) { + counts.identical += 1; + console.log(` ✓ ${label} identical`); + continue; + } + const dHits = DERIVED.map(() => 0); + const iHits = INTENDED.map(() => 0); + const resolved = substitute(mine, derivedPairs, dHits); + for (const envKey of added.stepEnv?.[stepKey(mine)] ?? []) { + if (resolved.env?.[envKey] === undefined) problems.push(`job "${jobName}": declared added env ${envKey} is gone`); + else delete resolved.env[envKey]; + } + const target = substitute(theirs, intendedPairs, iHits); + dHits.forEach((n, j) => { derivedHits[j] += n; }); + iHits.forEach((n, j) => { intendedHits[j] += n; }); + if (canon(resolved) === canon(target)) { + const why = [ + ...DERIVED.filter((_, j) => dHits[j]).map((d) => d.what), + ...INTENDED.filter((_, j) => iHits[j]).map((d) => `intended: ${d.what}`), + ]; + if (iHits.some(Boolean)) counts.intended += 1; else counts.derived += 1; + console.log(` ${iHits.some(Boolean) ? '~' : '✓'} ${label} ${why.join('; ')}`); + } else { + counts.divergent += 1; + problems.push(`job "${jobName}" step ${i + 1} "${stepKey(mine)}" differs\n had: ${canon(target)}\n gets: ${canon(resolved)}`); + console.log(` ✗ ${label} DIVERGENT`); + } + } + } + + // A declared difference that never applied means the shared workflow moved + // and this list did not — the proof would be checking a file that no longer exists. + DERIVED.forEach((d, j) => { if (!derivedHits[j]) problems.push(`declared derivation never applied: ${d.what}`); }); + + for (const problem of problems) console.log(` ✗ ${problem}`); + const steps = counts.identical + counts.derived + counts.intended + counts.added + counts.divergent; + console.log(` ${steps} steps: ${counts.identical} identical, ${counts.derived} identical once derived, ` + + `${counts.intended} intended (BE-213 pin), ${counts.added} added, ${counts.divergent} divergent` + + `${problems.length ? ` — ${problems.length} problem(s)` : ''}`); + divergent += problems.length; + for (const k of Object.keys(totals)) totals[k] += counts[k]; } -} -console.log(); -console.log(failures === 0 - ? ` all ${REPOS.length * 2} workflows resolve to the steps they replace` - : ` ${failures} workflow(s) would not do what they did before`); -process.exit(failures === 0 ? 0 : 1); + + console.log(); + console.log(` ${entries.length} estate(s): ${totals.identical} identical, ${totals.derived} identical once derived, ` + + `${totals.intended} intended, ${totals.added} added, ${totals.divergent} divergent step(s)`); + console.log(divergent === 0 + ? ` every caller + infra-diff.yml reproduces the workflow it replaces` + : ` ${divergent} problem(s): a caller would not do what its workflow did before`); + process.exit(divergent === 0 ? 0 : 1); +}; + +// ───────────────────────────────────────────────────────────────────────────── +const [first, ...rest] = process.argv.slice(2); +if (!first) usage(); +if (first === 'infra-diff') infraDiff(rest); +else if (existsSync(first)) libraries(first); +else usage();