From e357d5d81f30deafde39146b87dad5c9aa833ed5 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:45:19 +1100 Subject: [PATCH 1/5] Fix release stamping, packaging and publishing preflight --- .github/actions/marketplace-auth/action.yml | 54 +++++++++++++++ .../workflows/publish-vsix-per-platform.yml | 32 +++++---- .../release-dotnet-aot-multiplatform.yml | 50 ++++++++++---- .github/workflows/release.yml | 65 ++++++++++++++++--- docs/specs/ci-spec.md | 31 +++++++++ tools/shipwright-version-stamp.py | 65 ++++++++++++------- tools/test-version-stamp.py | 23 ++++++- 7 files changed, 259 insertions(+), 61 deletions(-) create mode 100644 .github/actions/marketplace-auth/action.yml diff --git a/.github/actions/marketplace-auth/action.yml b/.github/actions/marketplace-auth/action.yml new file mode 100644 index 00000000..baf2e978 --- /dev/null +++ b/.github/actions/marketplace-auth/action.yml @@ -0,0 +1,54 @@ +# Implements [SWR-VSIX-PUBLISH-AUTH]: one authentication path for preflight and publishing. +name: Verify Marketplace authentication +description: Prefer Entra OIDC when configured, otherwise use the configured Marketplace PAT. +inputs: + azure_client_id: + description: Entra workload identity client ID + required: false + azure_tenant_id: + description: Entra tenant ID + required: false + marketplace_pat: + description: Existing Marketplace publishing token + required: false +outputs: + flags: + description: Nonsecret authentication flag for vsce + value: ${{ steps.auth.outputs.flags }} +runs: + using: composite + steps: + - name: Azure login (OIDC) + if: inputs.azure_client_id != '' && inputs.azure_tenant_id != '' + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + with: + client-id: ${{ inputs.azure_client_id }} + tenant-id: ${{ inputs.azure_tenant_id }} + allow-no-subscriptions: true + - name: Select publishing credentials + id: auth + shell: bash + env: + CLIENT_ID: ${{ inputs.azure_client_id }} + TENANT_ID: ${{ inputs.azure_tenant_id }} + MARKETPLACE_PAT: ${{ inputs.marketplace_pat }} + run: | + set -euo pipefail + if [ -n "$CLIENT_ID" ] && [ -n "$TENANT_ID" ]; then + echo 'flags=--azure-credential' >> "$GITHUB_OUTPUT" + elif [ -n "$MARKETPLACE_PAT" ]; then + printf 'VSCE_PAT=%s\n' "$MARKETPLACE_PAT" >> "$GITHUB_ENV" + echo 'flags=' >> "$GITHUB_OUTPUT" + else + echo '::error::Marketplace publishing credentials are not configured.' >&2 + exit 1 + fi + - name: Verify publisher access without publishing + shell: bash + env: + AUTH_FLAGS: ${{ steps.auth.outputs.flags }} + run: | + set -euo pipefail + publisher="$(node -p "require('./Lql/LqlExtension/package.json').publisher")" + read -r -a auth_args <<< "$AUTH_FLAGS" + npx --yes @vscode/vsce@4.0.0 verify-pat "$publisher" "${auth_args[@]}" diff --git a/.github/workflows/publish-vsix-per-platform.yml b/.github/workflows/publish-vsix-per-platform.yml index a4822261..f07db1f5 100644 --- a/.github/workflows/publish-vsix-per-platform.yml +++ b/.github/workflows/publish-vsix-per-platform.yml @@ -1,7 +1,7 @@ # Adapted from Shipwright templates/gh-actions/publish-vsix-per-platform.yml # One VSIX per vsceTarget (never a fat native VSIX): build lql-lsp, stage it under # bin//, package, verify contents, attest provenance, then publish each VSIX -# to the Marketplace via Entra OIDC (no stored PAT). +# to the Marketplace via Entra OIDC or the configured Marketplace token. # [SWR-VSIX-PACKAGE] [SWR-VSIX-LAYOUT] [SWR-VSIX-VERIFY] [SWR-VSIX-PUBLISH-OIDC] [SWR-VSIX-PROVENANCE] name: publish-vsix-per-platform @@ -24,6 +24,8 @@ on: required: false azure_tenant_id: required: false + marketplace_pat: + required: false permissions: contents: read @@ -176,6 +178,9 @@ jobs: contents: read id-token: write steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: dist @@ -183,28 +188,27 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - # Entra workload-identity federation — no stored VSCE_PAT. [SWR-VSIX-PUBLISH-OIDC] - - name: Azure login (OIDC) - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + # Implements [SWR-VSIX-PUBLISH-AUTH]: reuse the same credential verification as preflight. + - name: Verify Marketplace publishing credentials + id: auth + uses: ./.github/actions/marketplace-auth with: - client-id: ${{ secrets.azure_client_id }} - tenant-id: ${{ secrets.azure_tenant_id }} - allow-no-subscriptions: true + azure_client_id: ${{ secrets.azure_client_id }} + azure_tenant_id: ${{ secrets.azure_tenant_id }} + marketplace_pat: ${{ secrets.marketplace_pat }} - name: Publish each VSIX to the Marketplace shell: bash env: PRE_RELEASE: ${{ inputs.pre_release }} + AUTH_FLAGS: ${{ steps.auth.outputs.flags }} run: | set -euo pipefail - VSCE_PAT="$(az account get-access-token \ - --resource 499b84ac-1321-427f-aa17-267ca6975798 \ - --query accessToken -o tsv)" - export VSCE_PAT - flag="" - if [ "$PRE_RELEASE" = "true" ]; then flag="--pre-release"; fi + flags=() + if [ "$PRE_RELEASE" = "true" ]; then flags+=(--pre-release); fi + read -r -a auth_args <<< "$AUTH_FLAGS" shopt -s nullglob for vsix in dist/*.vsix; do echo "::group::publish $vsix" - npx vsce publish --packagePath "$vsix" $flag + npx --yes @vscode/vsce@4.0.0 publish --packagePath "$vsix" "${flags[@]}" "${auth_args[@]}" --skip-duplicate echo "::endgroup::" done diff --git a/.github/workflows/release-dotnet-aot-multiplatform.yml b/.github/workflows/release-dotnet-aot-multiplatform.yml index adeaa199..7a157c2f 100644 --- a/.github/workflows/release-dotnet-aot-multiplatform.yml +++ b/.github/workflows/release-dotnet-aot-multiplatform.yml @@ -63,6 +63,9 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "10.0.x" + - name: Stamp version from tag + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "v${{ inputs.version }}" --root . # Native AOT needs the platform C toolchain; ubuntu/macos runners ship it, # Windows runners ship MSVC. Linux additionally needs clang + zlib headers. - name: Install Linux AOT toolchain @@ -88,17 +91,6 @@ jobs: test -f "$path" echo "path=$path" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" - - name: Native smoke test (migrate example schema → SQLite) - shell: bash - env: - DATAPROVIDERMIGRATE_AOT_BIN: ${{ steps.bin.outputs.path }} - run: | - set -euo pipefail - db="${RUNNER_TEMP}/aot_smoke.db" - "${DATAPROVIDERMIGRATE_AOT_BIN}" migrate \ - --schema Migration/DataProviderMigrate/example-schema.yaml \ - --output "$db" --provider sqlite - test -f "$db" - name: Package archive id: pkg shell: bash @@ -107,14 +99,44 @@ jobs: stage="${{ inputs.binary_name }}-${{ inputs.version }}-${{ matrix.platform }}" mkdir -p "${GITHUB_WORKSPACE}/dist" if [ "${{ runner.os }}" = "Windows" ]; then - 7z a "${GITHUB_WORKSPACE}/dist/${stage}.zip" "${{ steps.bin.outputs.path }}" >/dev/null + cd aot-out + 7z a "${GITHUB_WORKSPACE}/dist/${stage}.zip" . >/dev/null echo "archive=dist/${stage}.zip" >> "$GITHUB_OUTPUT" else tar -czf "${GITHUB_WORKSPACE}/dist/${stage}.tar.gz" \ - -C "$(dirname "${{ steps.bin.outputs.path }}")" \ - "$(basename "${{ steps.bin.outputs.path }}")" + -C aot-out . echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" fi + - name: Extract distributed archive + id: unpack + shell: bash + env: + ARCHIVE: ${{ steps.pkg.outputs.archive }} + EXE: ${{ steps.bin.outputs.exe }} + BINARY_NAME: ${{ inputs.binary_name }} + run: | + set -euo pipefail + dest="${RUNNER_TEMP}/aot-packaged" + mkdir -p "$dest" + if [ "${{ runner.os }}" = "Windows" ]; then + 7z x "$ARCHIVE" "-o${dest}" >/dev/null + else + tar -xzf "$ARCHIVE" -C "$dest" + fi + path="${dest}/${BINARY_NAME}${EXE}" + test -f "$path" + echo "path=$path" >> "$GITHUB_OUTPUT" + - name: Packaged native smoke test (migrate example schema → SQLite) + shell: bash + env: + DATAPROVIDERMIGRATE_AOT_BIN: ${{ steps.unpack.outputs.path }} + run: | + set -euo pipefail + db="${RUNNER_TEMP}/aot_smoke.db" + "${DATAPROVIDERMIGRATE_AOT_BIN}" migrate \ + --schema Migration/DataProviderMigrate/example-schema.yaml \ + --output "$db" --provider sqlite + test -f "$db" - name: Generate CycloneDX SBOM uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb044774..c1d00716 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,12 @@ name: Release on: + workflow_dispatch: + inputs: + version: + description: Version to preflight without publishing (for example 0.10.0-beta) + required: true + type: string push: tags: - "v[0-9]+.[0-9]+.[0-9]+" @@ -33,6 +39,7 @@ jobs: environment: release permissions: contents: read + id-token: write outputs: version: ${{ steps.v.outputs.version }} prerelease: ${{ steps.v.outputs.prerelease }} @@ -40,12 +47,14 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - name: Derive version from tag + - name: Derive release version id: v shell: bash + env: + RELEASE_TAG: ${{ inputs.version || github.ref_name }} run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" + version="${RELEASE_TAG#v}" echo "version=${version}" >> "$GITHUB_OUTPUT" if [[ "$version" == *-* ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" @@ -54,13 +63,22 @@ jobs: fi - name: Stamp version into runner working tree (never committed) shell: bash - run: python3 tools/shipwright-version-stamp.py --tag "${GITHUB_REF_NAME}" --root . + run: python3 tools/shipwright-version-stamp.py --tag "v${{ steps.v.outputs.version }}" --root . - name: Validate Shipwright manifests shell: bash run: | set -euo pipefail python3 tools/validate-manifest.py shipwright.json python3 tools/validate-manifest.py Lql/LqlExtension/shipwright.json + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - name: Verify Marketplace publishing credentials + uses: ./.github/actions/marketplace-auth + with: + azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} # Implements [CI-RELEASE-GATE]: run the same CI on the tagged SHA before publishing. ci-gate: @@ -82,6 +100,10 @@ jobs: # ── NuGet packages ──────────────────────────────────────────────────────── publish-nuget: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && + needs.codeql-gate.result == 'success' needs: [prepare, ci-gate, codeql-gate] runs-on: ubuntu-latest timeout-minutes: 15 @@ -128,6 +150,10 @@ jobs: # ── Per-platform binaries → GitHub Release ──────────────────────────────── release-binaries: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && + needs.codeql-gate.result == 'success' needs: [prepare, ci-gate, codeql-gate] uses: ./.github/workflows/release-binary-multiplatform.yml with: @@ -140,6 +166,10 @@ jobs: # ── Per-platform .NET Native AOT migration CLI → GitHub Release [MIG-AOT-CI] ─ release-dotnet-binaries: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && + needs.codeql-gate.result == 'success' needs: [prepare, ci-gate, codeql-gate] uses: ./.github/workflows/release-dotnet-aot-multiplatform.yml with: @@ -179,7 +209,9 @@ jobs: # ── Homebrew tap ────────────────────────────────────────────────────────── publish-brew: needs: [prepare, checksums] - if: needs.prepare.outputs.prerelease == 'false' + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.checksums.result == 'success' && needs.prepare.outputs.prerelease == 'false' uses: ./.github/workflows/publish-brew-tap.yml with: tap_repo: Nimblesite/homebrew-lql @@ -196,7 +228,9 @@ jobs: # ── Scoop bucket ────────────────────────────────────────────────────────── publish-scoop: needs: [prepare, checksums] - if: needs.prepare.outputs.prerelease == 'false' + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.checksums.result == 'success' && needs.prepare.outputs.prerelease == 'false' uses: ./.github/workflows/publish-scoop-bucket.yml with: bucket_repo: Nimblesite/scoop-lql @@ -239,7 +273,9 @@ jobs: # ── Homebrew tap (migration CLI) ────────────────────────────────────────── publish-brew-migrate: needs: [prepare, dotnet-checksums] - if: needs.prepare.outputs.prerelease == 'false' + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.dotnet-checksums.result == 'success' && needs.prepare.outputs.prerelease == 'false' uses: ./.github/workflows/publish-brew-tap.yml with: tap_repo: Nimblesite/homebrew-lql @@ -256,7 +292,9 @@ jobs: # ── Scoop bucket (migration CLI) ────────────────────────────────────────── publish-scoop-migrate: needs: [prepare, dotnet-checksums] - if: needs.prepare.outputs.prerelease == 'false' + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.dotnet-checksums.result == 'success' && needs.prepare.outputs.prerelease == 'false' uses: ./.github/workflows/publish-scoop-bucket.yml with: bucket_repo: Nimblesite/scoop-lql @@ -270,8 +308,12 @@ jobs: secrets: bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} - # ── Per-platform VSIX → VS Code Marketplace (Entra OIDC, no PAT) ─────────── + # ── Per-platform VSIX → VS Code Marketplace ─────────────────────────────── publish-vsix: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && + needs.codeql-gate.result == 'success' && needs.release-binaries.result == 'success' needs: [prepare, ci-gate, codeql-gate, release-binaries] uses: ./.github/workflows/publish-vsix-per-platform.yml with: @@ -281,6 +323,7 @@ jobs: secrets: azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} permissions: contents: read id-token: write @@ -288,6 +331,9 @@ jobs: # ── Documentation sites (both deploy on release) ────────────────────────── deploy-lql-website: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.publish-nuget.result == 'success' needs: [prepare, publish-nuget] uses: ./.github/workflows/deploy-lql-website.yml with: @@ -298,6 +344,9 @@ jobs: id-token: write deploy-website: + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + needs.publish-nuget.result == 'success' && needs.deploy-lql-website.result == 'success' needs: [prepare, publish-nuget, deploy-lql-website] uses: ./.github/workflows/deploy-pages.yml with: diff --git a/docs/specs/ci-spec.md b/docs/specs/ci-spec.md index 4a19cf84..1de0d010 100644 --- a/docs/specs/ci-spec.md +++ b/docs/specs/ci-spec.md @@ -8,6 +8,37 @@ workflows check out the tagged SHA. The CI aggregate requires success from every build, test, lint, and duplication job; only dependency review may be skipped for non-PR events. CI also runs `tools/test-version-stamp.py`, which checks stable and prerelease stamping and dry-run immutability. +It also verifies that locked Cargo workspace packages receive the tag version +without changing registry dependencies, and that `cargo metadata --locked` +accepts the stamped lockfile. Native AOT builds stamp their checkout before +publishing so the migration CLI carries the release version. +Native CLI archives include the complete publish output, including SQLite's +native runtime library. Each platform extracts its archive and runs the existing +migration smoke test from the extracted directory before uploading artifacts. + +### Marketplace Version Carrier [SWR-VSIX-PACKAGE] + +VS Code Marketplace accepts only numeric `major.minor.patch` extension versions. +The VSIX package manifest therefore uses the numeric portion of a prerelease +tag; the publish workflow sets `--pre-release` to select the prerelease channel. +Native binaries and Shipwright `expectedVersion` retain the complete tag version, +including its suffix. The stamper tests verify both representations, preserving +every version assertion and dry-run immutability check. + +### Publishing Preflight [CI-RELEASE-PREFLIGHT] + +Manual Release runs take a version and verify manifests, publishing credentials, +CI, and the CodeQL security gate on the selected commit. Publishing jobs require +a tag push, so manual preflight cannot upload packages or release assets. + +### Marketplace Authentication [SWR-VSIX-PUBLISH-AUTH] + +Preflight and VSIX publishing share one composite action. It prefers Entra OIDC +when both identity IDs are configured; otherwise it uses the existing +`VSCODE_MARKETPLACE_PAT` secret. It verifies publisher access before publishing, +fails when credentials are missing or invalid, and never falls back after an +OIDC login failure. The supported Azure credential option is passed directly to +the pinned VSCE CLI. Retrying publication skips already published packages. ## Rust Coverage Instrumentation [CI-RUST-COVERAGE] diff --git a/tools/shipwright-version-stamp.py b/tools/shipwright-version-stamp.py index a4151cf1..223dab30 100644 --- a/tools/shipwright-version-stamp.py +++ b/tools/shipwright-version-stamp.py @@ -9,7 +9,8 @@ Carriers stamped: - Directory.Build.props (XML, ElementTree) - Lql/lql-lsp-rust/Cargo.toml [workspace.package].version (TOML, table-aware walk) - - Lql/LqlExtension/package.json version (JSON) + - Lql/lql-lsp-rust/Cargo.lock workspace package versions only (TOML) + - Lql/LqlExtension/package.json numeric version for Marketplace (JSON) - package.json version (JSON, only if present) - shipwright.json product.version + every expectedVersion (JSON) - Lql/LqlExtension/shipwright.json product.version + every expectedVersion (JSON) @@ -64,38 +65,51 @@ def stamp_msbuild(path: Path, version: str, dry_run: bool) -> list[str]: return changed -def stamp_cargo_workspace(path: Path, version: str, dry_run: bool) -> list[str]: - """Rewrite [workspace.package].version with a table-aware line walk. - - tomllib (structured read) validates the file and confirms the key exists; - the write is a single targeted line replacement guarded by the active table, - never a regex over the document. - """ - parsed = tomllib.loads(path.read_text(encoding="utf-8")) - if "version" not in parsed.get("workspace", {}).get("package", {}): - return [] +def stamp_toml_versions(path: Path, version: str, dry_run: bool, table: str, targets: set[int]) -> list[str]: + """Preserve TOML text outside selected version keys. [SWR-VERSION-BUILD-STAMPING]""" lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - out: list[str] = [] active_table: str | None = None + table_index = -1 changed: list[str] = [] - for line in lines: + for index, line in enumerate(lines): stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): - active_table = stripped[1:-1].strip() - out.append(line) - continue - key = stripped.split("=", 1)[0].strip() if "=" in stripped else "" - if active_table == "workspace.package" and key == "version" and not changed: + active_table = stripped + table_index += int(active_table == table) + if active_table == table and table_index in targets and stripped.partition("=")[0].strip() == "version": eol = "\r\n" if line.endswith("\r\n") else "\n" - out.append(f'version = "{version}"{eol}') - changed.append(f"{path}: [workspace.package].version -> {version}") - continue - out.append(line) + lines[index] = f'version = "{version}"{eol}' + changed.append(f"{path}: {table}[{table_index}].version -> {version}") if changed and not dry_run: - path.write_text("".join(out), encoding="utf-8") + path.write_text("".join(lines), encoding="utf-8") return changed +def stamp_cargo_lock(path: Path, version: str, dry_run: bool) -> list[str]: + """Update workspace entries without resolving dependencies. [SWR-VERSION-BUILD-STAMPING]""" + lock = path.parent / "Cargo.lock" + if not lock.exists(): + return [] + workspace = tomllib.loads(path.read_text(encoding="utf-8"))["workspace"] + packages = ( + tomllib.loads((path.parent / member / "Cargo.toml").read_text(encoding="utf-8"))["package"] + for member in workspace["members"] + ) + names = {package["name"] for package in packages if package.get("version") == {"workspace": True}} + entries = tomllib.loads(lock.read_text(encoding="utf-8"))["package"] + targets = {index for index, package in enumerate(entries) if package["name"] in names and "source" not in package} + return stamp_toml_versions(path=lock, version=version, dry_run=dry_run, table="[[package]]", targets=targets) + + +def stamp_cargo_workspace(path: Path, version: str, dry_run: bool) -> list[str]: + """Stamp the manifest and its locked workspace entries. [SWR-VERSION-BUILD-STAMPING]""" + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + if "version" not in parsed.get("workspace", {}).get("package", {}): + return [] + changed = stamp_toml_versions(path=path, version=version, dry_run=dry_run, table="[workspace.package]", targets={0}) + return changed + stamp_cargo_lock(path=path, version=version, dry_run=dry_run) + + def main() -> int: ap = argparse.ArgumentParser(description="Shipwright version stamper") ap.add_argument("--tag", required=True, help="Release tag, e.g. v1.2.3") @@ -120,7 +134,10 @@ def main() -> int: for path, kind in carriers: if not path.exists(): continue - all_changes.extend(handlers[kind](path, version, args.dry_run)) + # Marketplace represents prereleases through --pre-release, not a suffix. + # Implements [SWR-VSIX-PACKAGE]; binary expectedVersion retains the full tag. + carrier_version = version.partition("-")[0] if path == root / "Lql/LqlExtension/package.json" else version + all_changes.extend(handlers[kind](path=path, version=carrier_version, dry_run=args.dry_run)) mode = "DRY-RUN (no files changed)" if args.dry_run else "STAMPED" print(f"shipwright-version-stamp {mode}: tag={args.tag} version={version} root={root}") diff --git a/tools/test-version-stamp.py b/tools/test-version-stamp.py index 3cc12a8e..b56bac9b 100644 --- a/tools/test-version-stamp.py +++ b/tools/test-version-stamp.py @@ -29,7 +29,8 @@ def assert_versions(root: Path, version: str) -> None: for c in data["components"] if "expectedVersion" in c ) else: - assert data["version"] == version + expected = version.partition("-")[0] if name == "Lql/LqlExtension/package.json" else version + assert data["version"] == expected def run_stamp(root: Path, version: str, dry_run: bool = False) -> None: @@ -59,5 +60,25 @@ def test_release_carriers() -> None: assert {name: (ROOT / name).read_bytes() for name in CARRIERS} == original +def test_release_preserves_locked_cargo_dependencies() -> None: + """Release stamping remains buildable with --locked. [SWR-VERSION-BUILD-STAMPING]""" + cargo_root = Path("Lql/lql-lsp-rust") + original = tomllib.loads((ROOT / cargo_root / "Cargo.lock").read_text())["package"] + registry = [package for package in original if "source" in package] + with tempfile.TemporaryDirectory(prefix="version-lock-ci-") as directory: + root = Path(directory) + shutil.copytree(src=ROOT / cargo_root, dst=root / cargo_root, ignore=shutil.ignore_patterns("target")) + for version in ("1.2.3", "1.2.4-beta.5"): + run_stamp(root=root, version=version) + packages = tomllib.loads((root / cargo_root / "Cargo.lock").read_text())["package"] + assert [package for package in packages if "source" in package] == registry + assert {package["version"] for package in packages if "source" not in package} == {version} + subprocess.run( + args=["cargo", "metadata", "--locked", "--offline", "--format-version", "1"], + cwd=root / cargo_root, check=True, timeout=30, stdout=subprocess.DEVNULL, + ) + + if __name__ == "__main__": test_release_carriers() + test_release_preserves_locked_cargo_dependencies() From d5ad6133a4df92b6383b7eeaa25c61cf11512e60 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:59:49 +1100 Subject: [PATCH 2/5] Fetch locked cross-platform dependencies before stamper tests --- .github/workflows/ci.yml | 2 +- Makefile | 33 ++++++++++----------------------- docs/specs/ci-spec.md | 4 +++- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caf4ac99..88a918d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: run: make lint - name: Test release version stamper - run: python3 tools/test-version-stamp.py + run: make _test_version_stamp - name: Install website dependencies run: npm ci --prefix Website --ignore-scripts --no-audit --no-fund diff --git a/Makefile b/Makefile index b9cf56a1..529ca3b8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ # agent-pmo:795a9c2 -# Standard Makefile — Nimblesite.DataProvider.Core -# Cross-platform: Linux, macOS, Windows (via GNU Make) -# All targets are language-agnostic. Add language-specific helpers below. .PHONY: build test lint fmt clean ci setup check coverage vsix rebuild-install-vsix clinical help @@ -58,12 +55,7 @@ DOTNET_TEST_PROJECTS = \ Reporting/Nimblesite.Reporting.Tests \ Reporting/Nimblesite.Reporting.Integration.Tests -# Standard Targets -# -# Portfolio-wide uniform interface — these 7 targets exist in every repo and -# their names never change. See REPO-STANDARDS-SPEC [MAKE-TARGETS]. -# Do NOT add extra public targets here — put them in the Repo-Specific -# Targets section below. +# Standard targets implement REPO-STANDARDS-SPEC [MAKE-TARGETS]. ## build: Compile/assemble all artifacts build: @@ -74,7 +66,7 @@ build: ## See REPO-STANDARDS-SPEC [TEST-RULES]. test: @echo "==> Testing..." - python3 tools/test-version-stamp.py + $(MAKE) _test_version_stamp npm ci --prefix Website --ignore-scripts --no-audit --no-fund python3 Website/scripts/test-api-docs.py $(MAKE) _test @@ -103,10 +95,7 @@ setup: $(MAKE) _setup @echo "==> Setup complete. Run 'make ci' to validate." -# Repo-Specific Targets (NOT part of the portfolio standard 7) -# -# Add helpers unique to this repo below. They MUST NOT shadow any of the 7 -# standard targets above. See REPO-STANDARDS-SPEC [MAKE-TARGETS]. +# Repo-specific targets extend [MAKE-TARGETS]. ## check: lint + test (pre-commit shortcut) check: lint test @@ -145,6 +134,12 @@ _vsix_clean: $(RM) Lql/LqlExtension/out -$(RM) Lql/LqlExtension/*.vsix +# Implements [SWR-VERSION-BUILD-STAMPING]: offline metadata needs every target dependency. +.PHONY: _test_version_stamp +_test_version_stamp: + cargo fetch --locked --manifest-path Lql/lql-lsp-rust/Cargo.toml + python3 tools/test-version-stamp.py + # LANGUAGE-SPECIFIC IMPLEMENTATIONS _build: _build_dotnet _build_rust _build_ts @@ -164,15 +159,7 @@ _coverage: _coverage_dotnet _setup: _setup_dotnet _setup_ts -# COVERAGE ENFORCEMENT (shared shell logic) -# Each test target collects coverage, compares against coverage-thresholds.json, -# fails hard if below, and ratchets up if above. -# -# coverage-thresholds.json keys are SOURCE project paths, e.g.: -# "DataProvider/Nimblesite.DataProvider.Core": { "threshold": 88, "include": "..." } -# -# The SRC_KEY mapping converts test project paths -> source project keys. -# CI calls these same make targets — no duplication. +# Coverage maps test projects to source keys, enforces thresholds, and ratchets upward. # --- C#/.NET --- _build_dotnet: diff --git a/docs/specs/ci-spec.md b/docs/specs/ci-spec.md index 1de0d010..3b59c3c8 100644 --- a/docs/specs/ci-spec.md +++ b/docs/specs/ci-spec.md @@ -10,7 +10,9 @@ skipped for non-PR events. CI also runs `tools/test-version-stamp.py`, which checks stable and prerelease stamping and dry-run immutability. It also verifies that locked Cargo workspace packages receive the tag version without changing registry dependencies, and that `cargo metadata --locked` -accepts the stamped lockfile. Native AOT builds stamp their checkout before +accepts the stamped lockfile. Make and CI share one test target that fetches +the complete locked dependency graph before offline metadata verification, +including dependencies for other platforms. Native AOT builds stamp their checkout before publishing so the migration CLI carries the release version. Native CLI archives include the complete publish output, including SQLite's native runtime library. Each platform extracts its archive and runs the existing From 0f549cfb9717168bda19c405a0846f2897144fe2 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:08:13 +1100 Subject: [PATCH 3/5] Disable Marketplace publishing in release workflow --- .github/workflows/release.yml | 55 ++++++++++++++++++----------------- docs/specs/ci-spec.md | 9 ++++-- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1d00716..dc2c45c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ # Tag-triggered release. Stamps the tag version into the runner working tree only # (NO commit/push/tag bump after the tag exists — [SWR-VERSION-BUILD-STAMPING]), # runs the CI + CodeQL hard gate, then publishes NuGet packages, per-platform binaries -# (GitHub Release), per-platform VSIX (Marketplace), Homebrew + Scoop, and deploys +# (GitHub Release), Homebrew + Scoop, and deploys # both documentation sites. [SWR-REL-WORKFLOW] [SWR-REL-GITHUB] name: Release @@ -39,7 +39,6 @@ jobs: environment: release permissions: contents: read - id-token: write outputs: version: ${{ steps.v.outputs.version }} prerelease: ${{ steps.v.outputs.prerelease }} @@ -73,12 +72,13 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - - name: Verify Marketplace publishing credentials - uses: ./.github/actions/marketplace-auth - with: - azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} - azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} - marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} + # Marketplace publishing is disabled at the user's request. [SWR-VSIX-PUBLISH-AUTH] + # - name: Verify Marketplace publishing credentials + # uses: ./.github/actions/marketplace-auth + # with: + # azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + # azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + # marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} # Implements [CI-RELEASE-GATE]: run the same CI on the tagged SHA before publishing. ci-gate: @@ -309,25 +309,26 @@ jobs: bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} # ── Per-platform VSIX → VS Code Marketplace ─────────────────────────────── - publish-vsix: - if: >- - github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && - needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && - needs.codeql-gate.result == 'success' && needs.release-binaries.result == 'success' - needs: [prepare, ci-gate, codeql-gate, release-binaries] - uses: ./.github/workflows/publish-vsix-per-platform.yml - with: - version: ${{ needs.prepare.outputs.version }} - pre_release: ${{ needs.prepare.outputs.prerelease == 'true' }} - marketplace_environment: release - secrets: - azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} - azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} - marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} - permissions: - contents: read - id-token: write - attestations: write + # Disabled at the user's request. [SWR-VSIX-PUBLISH-AUTH] + # publish-vsix: + # if: >- + # github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && + # needs.prepare.result == 'success' && needs.ci-gate.result == 'success' && + # needs.codeql-gate.result == 'success' && needs.release-binaries.result == 'success' + # needs: [prepare, ci-gate, codeql-gate, release-binaries] + # uses: ./.github/workflows/publish-vsix-per-platform.yml + # with: + # version: ${{ needs.prepare.outputs.version }} + # pre_release: ${{ needs.prepare.outputs.prerelease == 'true' }} + # marketplace_environment: release + # secrets: + # azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + # azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + # marketplace_pat: ${{ secrets.VSCODE_MARKETPLACE_PAT }} + # permissions: + # contents: read + # id-token: write + # attestations: write # ── Documentation sites (both deploy on release) ────────────────────────── deploy-lql-website: diff --git a/docs/specs/ci-spec.md b/docs/specs/ci-spec.md index 3b59c3c8..834c8d9b 100644 --- a/docs/specs/ci-spec.md +++ b/docs/specs/ci-spec.md @@ -3,7 +3,7 @@ ## Release CI Gate [CI-RELEASE-GATE] Tag releases call the same reusable CI workflow used by pull requests and the -CodeQL workflow before publishing packages, binaries, or VSIX files. Both +CodeQL workflow before publishing packages or binaries. Both workflows check out the tagged SHA. The CI aggregate requires success from every build, test, lint, and duplication job; only dependency review may be skipped for non-PR events. CI also runs `tools/test-version-stamp.py`, which @@ -29,13 +29,16 @@ every version assertion and dry-run immutability check. ### Publishing Preflight [CI-RELEASE-PREFLIGHT] -Manual Release runs take a version and verify manifests, publishing credentials, +Manual Release runs take a version and verify manifests, CI, and the CodeQL security gate on the selected commit. Publishing jobs require a tag push, so manual preflight cannot upload packages or release assets. ### Marketplace Authentication [SWR-VSIX-PUBLISH-AUTH] -Preflight and VSIX publishing share one composite action. It prefers Entra OIDC +Marketplace publication and its credential preflight are commented out in the +Release workflow at the user's request. NuGet releases require no Marketplace +credentials. The retained VSIX publishing workflow uses one composite action, +which prefers Entra OIDC when both identity IDs are configured; otherwise it uses the existing `VSCODE_MARKETPLACE_PAT` secret. It verifies publisher access before publishing, fails when credentials are missing or invalid, and never falls back after an From d5b9549316e1d49103d0b22834017973b92b41c4 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:49:17 +1100 Subject: [PATCH 4/5] Fix invalid release SBOM and provenance action pins --- .github/workflows/publish-vsix-per-platform.yml | 2 +- .github/workflows/release-binary-multiplatform.yml | 6 +++--- .github/workflows/release-dotnet-aot-multiplatform.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-vsix-per-platform.yml b/.github/workflows/publish-vsix-per-platform.yml index f07db1f5..e4a4759d 100644 --- a/.github/workflows/publish-vsix-per-platform.yml +++ b/.github/workflows/publish-vsix-per-platform.yml @@ -160,7 +160,7 @@ jobs: echo "::error::TypeScript source leaked into $vsix"; exit 1 fi - name: Attest VSIX provenance - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: dist/lql-language-support-${{ matrix.target }}-${{ inputs.version }}.vsix - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 diff --git a/.github/workflows/release-binary-multiplatform.yml b/.github/workflows/release-binary-multiplatform.yml index 0687c1a8..ba88c50b 100644 --- a/.github/workflows/release-binary-multiplatform.yml +++ b/.github/workflows/release-binary-multiplatform.yml @@ -97,17 +97,17 @@ jobs: echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" fi - name: Generate CycloneDX SBOM - uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + uses: anchore/sbom-action@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: file: ${{ steps.pkg.outputs.archive }} format: cyclonedx-json output-file: ${{ steps.pkg.outputs.archive }}.cdx.json - name: Attest build provenance - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} - name: Attest SBOM - uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0 with: subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json diff --git a/.github/workflows/release-dotnet-aot-multiplatform.yml b/.github/workflows/release-dotnet-aot-multiplatform.yml index 7a157c2f..de3b9ec8 100644 --- a/.github/workflows/release-dotnet-aot-multiplatform.yml +++ b/.github/workflows/release-dotnet-aot-multiplatform.yml @@ -138,17 +138,17 @@ jobs: --output "$db" --provider sqlite test -f "$db" - name: Generate CycloneDX SBOM - uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + uses: anchore/sbom-action@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 with: file: ${{ steps.pkg.outputs.archive }} format: cyclonedx-json output-file: ${{ steps.pkg.outputs.archive }}.cdx.json - name: Attest build provenance - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} - name: Attest SBOM - uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0 with: subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json From 1b9e47c74dc4d772aa7adf2086e47cadf5f09e96 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:39:19 +1100 Subject: [PATCH 5/5] Fix PostgreSQL 15 RLS predicate comparison context --- Migration/DataProviderMigrate/Program.cs | 5 +- .../PostgresPolicyCatalogNormalizer.cs | 21 ++ .../Postgres15ContainerFixture.cs | 14 ++ .../Postgres15TestSuite.cs | 7 + .../PostgresPolicyCatalogCliTests.cs | 205 ++++++++++++++++++ Tests.Shared/PostgresContainerFixture.cs | 7 +- 6 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15ContainerFixture.cs create mode 100644 Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15TestSuite.cs create mode 100644 Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogCliTests.cs diff --git a/Migration/DataProviderMigrate/Program.cs b/Migration/DataProviderMigrate/Program.cs index b2c8bba6..6e201f8b 100644 --- a/Migration/DataProviderMigrate/Program.cs +++ b/Migration/DataProviderMigrate/Program.cs @@ -218,7 +218,7 @@ MigratePhase phase schema, allowDestructive, phase, - () => PostgresSchemaInspector.Inspect(connection, "public"), + () => PostgresPolicyCatalogNormalizer.Inspect(connection: connection), ops => MigrationRunner.Apply( connection, @@ -229,7 +229,8 @@ MigratePhase phase VerifySchemaIntegrity( schema, phase, - () => PostgresSchemaInspector.Inspect(connection, "public"), + () => + PostgresPolicyCatalogNormalizer.Inspect(connection: connection), (live, target) => PostgresPolicyCatalogNormalizer.Normalize( connection, diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs index 6fec2e6d..48390c5b 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs @@ -5,6 +5,13 @@ string, Nimblesite.DataProvider.Migration.Core.MigrationError >.Ok; +using SchemaOk = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; namespace Nimblesite.DataProvider.Migration.Postgres; @@ -15,6 +22,20 @@ namespace Nimblesite.DataProvider.Migration.Postgres; /// public static class PostgresPolicyCatalogNormalizer { + /// + /// Inspect the live schema with policy predicates in the same view context + /// used for desired predicates. Implements [RLS-DIFF] and issue #119. + /// PostgreSQL 15 qualifies view columns differently from pg_policies; + /// normalizing both preserves correlated references and subquery shadowing. + /// + public static SchemaResult Inspect(NpgsqlConnection connection) + { + var result = PostgresSchemaInspector.Inspect(connection: connection, schemaName: "public"); + return result is SchemaOk ok + ? Normalize(connection: connection, live: ok.Value, desired: ok.Value) + : result; + } + /// Canonicalize policies on tables that already exist. public static SchemaResult Normalize( NpgsqlConnection connection, diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15ContainerFixture.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15ContainerFixture.cs new file mode 100644 index 00000000..c58c5bb0 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15ContainerFixture.cs @@ -0,0 +1,14 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [RLS-DIFF]: PostgreSQL 15 deparses view and policy columns differently. +public sealed record Postgres15ContainerFixture : IAsyncLifetime +{ + private readonly PostgresContainerFixture _fixture = new(); + + public Task InitializeAsync() => _fixture.InitializeAsync(image: "postgres:15.1"); + + public Task DisposeAsync() => _fixture.DisposeAsync(); + + public NpgsqlConnection CreateDatabase(string namePrefix) => + _fixture.CreateDatabase(namePrefix: namePrefix); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15TestSuite.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15TestSuite.cs new file mode 100644 index 00000000..ec6bab1c --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Postgres15TestSuite.cs @@ -0,0 +1,7 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +[CollectionDefinition(Name)] +public sealed class Postgres15TestSuite : ICollectionFixture +{ + public const string Name = "Postgres15"; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogCliTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogCliTests.cs new file mode 100644 index 00000000..67864ffc --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogCliTests.cs @@ -0,0 +1,205 @@ +using IntegrityOk = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok< + System.Collections.Immutable.ImmutableArray, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; + +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [RLS-DIFF]. Issue #119: view qualification must not become policy drift. +[Collection(Postgres15TestSuite.Name)] +public sealed class PostgresPolicyCatalogCliTests(Postgres15ContainerFixture fixture) +{ + [Theory] + [InlineData("id = 1", "id = 2", null, null, false)] + [InlineData("id = 1", "items.id = 2", "id > 0", "id > 0", true)] + [InlineData("id > 0", "id > 0", "id = 1", "id = 2", false)] + [InlineData(null, null, "id = 1", "id = 2", false)] + [InlineData("NOT (id = 1)", "NOT (id = 2)", null, null, true)] + [InlineData("abs(id) = 1", "abs(id) = 2", null, null, true)] + [InlineData("\"id\" = 1", "\"id\" = 2", null, null, true)] + [InlineData( + "EXISTS (SELECT 1 FROM public.items AS other WHERE other.id = items.id)", + "EXISTS (SELECT 1 FROM public.items AS other WHERE other.id = id)", + null, + null, + true + )] + public void ColumnPredicates_CreateReapplyAndChangeWithoutFalseIntegrityDrift( + string? initialUsing, + string? changedUsing, + string? initialCheck, + string? changedCheck, + bool sql + ) + { + using var connection = fixture.CreateDatabase(namePrefix: "policy_column_repro"); + var initial = PolicyYaml(predicate: initialUsing, check: initialCheck, sql: sql); + var changed = PolicyYaml(predicate: changedUsing, check: changedCheck, sql: sql); + AssertSuccessfulMigration( + connection: connection, + yaml: initial, + expected: "CreateRlsPolicyOperation" + ); + var before = ReadPolicy(connection: connection); + AssertSuccessfulMigration( + connection: connection, + yaml: initial, + expected: "no operations needed" + ); + AssertSuccessfulMigration( + connection: connection, + yaml: changed, + expected: "AlterRlsPolicyOperation" + ); + var after = ReadPolicy(connection: connection); + Assert.Equal( + expected: initialUsing == changedUsing, + actual: before.UsingSql == after.UsingSql + ); + Assert.Equal( + expected: initialCheck == changedCheck, + actual: before.WithCheckSql == after.WithCheckSql + ); + AssertSuccessfulMigration( + connection: connection, + yaml: changed, + expected: "no operations needed" + ); + } + + [Fact] + public void RealPredicateDrift_FailsIntegrityAndRollsBackPolicyAlteration() + { + using var connection = fixture.CreateDatabase(namePrefix: "policy_drift_rollback"); + var yaml = PolicyYaml(predicate: "id = 1", check: null, sql: false); + AssertSuccessfulMigration( + connection: connection, + yaml: yaml, + expected: "CreateRlsPolicyOperation" + ); + var desired = SchemaYamlSerializer.FromYaml(yaml: yaml); + var before = ReadPolicy(connection: connection); + var result = MigrationRunner.Apply( + connection: connection, + operations: + [ + new AlterRlsPolicyOperation( + Schema: "public", + TableName: "items", + Policy: before with + { + UsingSql = "id = 2", + } + ), + ], + generateDdl: PostgresDdlGenerator.Generate, + options: MigrationOptions.Default, + verifyBeforeCommit: () => RejectDrift(connection: connection, desired: desired) + ); + var error = Assert.IsType(@object: result); + Assert.Equal( + expected: "Schema integrity verification failed before commit", + actual: error.Value.Message + ); + Assert.Equivalent( + expected: before, + actual: ReadPolicy(connection: connection), + strict: true + ); + } + + private static bool RejectDrift(NpgsqlConnection connection, SchemaDefinition desired) + { + var live = Assert + .IsType(PostgresPolicyCatalogNormalizer.Inspect(connection: connection)) + .Value; + var normalized = Assert + .IsType( + PostgresPolicyCatalogNormalizer.Normalize( + connection: connection, + live: live, + desired: desired + ) + ) + .Value; + var mismatches = Assert + .IsType(SchemaIntegrityVerifier.Verify(live: live, desired: normalized)) + .Value; + Assert.Contains( + expected: "public.items: policy item_visible USING predicate drifted", + collection: mismatches + ); + return mismatches.IsEmpty; + } + + private static void AssertSuccessfulMigration( + NpgsqlConnection connection, + string yaml, + string expected + ) + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path: path, contents: yaml); + var result = MigrationCliConsole.Migrate( + schemaPath: path, + provider: "postgres", + output: connection.ConnectionString + ); + Assert.True(condition: result.ExitCode == 0, userMessage: result.Output); + Assert.Contains( + expectedSubstring: expected, + actualString: result.Output, + comparisonType: StringComparison.Ordinal + ); + } + finally + { + File.Delete(path: path); + } + } + + private static RlsPolicyDefinition ReadPolicy(NpgsqlConnection connection) => + Assert.Single( + Assert + .Single( + Assert + .IsType( + PostgresSchemaInspector.Inspect(connection: connection) + ) + .Value.Tables + ) + .RowLevelSecurity?.Policies + ?? [] + ); + + private static string PolicyYaml(string? predicate, string? check, bool sql) => + $$""" + name: nap_policy_repro + tables: + - name: items + schema: public + columns: + - name: id + type: Int + isNullable: false + primaryKey: + columns: [id] + rowLevelSecurity: + enabled: true + forced: true + policies: + - name: item_visible + permissive: true + operations: [{{(check is null ? "Select" : predicate is null ? "Insert" : "All")}}] + roles: [PUBLIC] + {{(predicate is null ? "" : $"{(sql ? "usingSql" : "using")}: '{predicate}'")}} + {{( + check is null ? "" : $"{(sql ? "withCheckSql" : "withCheck")}: '{check}'" + )}} + """; +} diff --git a/Tests.Shared/PostgresContainerFixture.cs b/Tests.Shared/PostgresContainerFixture.cs index de8ceb00..4310dc80 100644 --- a/Tests.Shared/PostgresContainerFixture.cs +++ b/Tests.Shared/PostgresContainerFixture.cs @@ -30,7 +30,10 @@ public sealed class PostgresContainerFixture : IAsyncLifetime /// /// Starts the shared container. Called once per collection fixture lifetime. /// - public async Task InitializeAsync() + public Task InitializeAsync() => InitializeAsync(image: "pgvector/pgvector:pg16"); + + /// Starts the shared fixture using the specified PostgreSQL image. + public async Task InitializeAsync(string image) { // Implements [MIG-TEST-NATIVE-POSTGRES]: use an isolated native server when configured. if ( @@ -46,7 +49,7 @@ public async Task InitializeAsync() // test that needs vector columns (VectorType). Non-vector tests are // unaffected. _container = new PostgreSqlBuilder() - .WithImage("pgvector/pgvector:pg16") + .WithImage(image) .WithDatabase("postgres") .WithUsername("test") .WithPassword("test")