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/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/.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/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 4a19cf84..3b59c3c8 100644 --- a/docs/specs/ci-spec.md +++ b/docs/specs/ci-spec.md @@ -8,6 +8,39 @@ 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. 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 +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()