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..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..abbba20 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,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: @@ -138,9 +177,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/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(); 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"] +}