lightnow-cli 1.5.0__tar.gz → 1.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/ci.yml +25 -6
  2. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/release-please.yml +1 -1
  3. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/release.yml +11 -11
  4. lightnow_cli-1.6.1/.release-please-manifest.json +3 -0
  5. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/CHANGELOG.md +21 -0
  6. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/PKG-INFO +44 -3
  7. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/README.md +42 -1
  8. lightnow_cli-1.6.1/ci/create_client_artifact.py +126 -0
  9. lightnow_cli-1.6.1/ci/test_client_artifact.py +124 -0
  10. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/docs/release.md +23 -0
  11. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/__init__.py +1 -1
  12. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/auth.py +70 -24
  13. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/query.py +1 -1
  14. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/config.py +217 -7
  15. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/main.py +4 -4
  16. lightnow_cli-1.6.1/lightnow_cli/target.py +102 -0
  17. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/pyproject.toml +1 -1
  18. lightnow_cli-1.6.1/renovate.json +47 -0
  19. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_auth_flow.py +4 -2
  20. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_cli.py +73 -30
  21. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_config.py +4 -2
  22. lightnow_cli-1.6.1/tests/test_connection_target.py +535 -0
  23. lightnow_cli-1.5.0/.release-please-manifest.json +0 -3
  24. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.gitignore +0 -0
  25. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/CONTRIBUTING.md +0 -0
  26. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/LICENSE +0 -0
  27. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/Makefile +0 -0
  28. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/SECURITY.md +0 -0
  29. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/docs/development.md +0 -0
  30. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/README.md +0 -0
  31. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/docs.md +0 -0
  32. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/openapi.json +0 -0
  33. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/server.json +0 -0
  34. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/authenticated_http.py +0 -0
  35. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/client.py +0 -0
  36. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/__init__.py +0 -0
  37. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/context.py +0 -0
  38. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/integrations.py +0 -0
  39. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/publish.py +0 -0
  40. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/runner.py +0 -0
  41. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/updates.py +0 -0
  42. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/validate.py +0 -0
  43. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/tls.py +0 -0
  44. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/updates.py +0 -0
  45. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/validation.py +0 -0
  46. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/release-please-config.json +0 -0
  47. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/__init__.py +0 -0
  48. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_authenticated_http.py +0 -0
  49. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_client.py +0 -0
  50. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_context.py +0 -0
  51. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_integrations.py +0 -0
  52. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_tls.py +0 -0
  53. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_updates.py +0 -0
  54. {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_validation.py +0 -0
@@ -23,10 +23,10 @@ jobs:
23
23
 
24
24
  steps:
25
25
  - name: Checkout
26
- uses: actions/checkout@v4
26
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
27
27
 
28
28
  - name: Set up Python
29
- uses: actions/setup-python@v5
29
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
30
30
  with:
31
31
  python-version: ${{ matrix.python-version }}
32
32
  cache: pip
@@ -59,12 +59,12 @@ jobs:
59
59
 
60
60
  steps:
61
61
  - name: Checkout
62
- uses: actions/checkout@v4
62
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
63
63
 
64
64
  - name: Set up Python
65
- uses: actions/setup-python@v5
65
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
66
66
  with:
67
- python-version: "3.13"
67
+ python-version: "3.12"
68
68
  cache: pip
69
69
 
70
70
  - name: Install build tools
@@ -73,8 +73,27 @@ jobs:
73
73
  - name: Build and check distributions
74
74
  run: make package
75
75
 
76
+ - name: Verify distribution provenance contract
77
+ run: python -m unittest discover -s ci -p 'test_*.py'
78
+
79
+ - name: Bind PR distribution provenance
80
+ if: github.event_name == 'pull_request'
81
+ env:
82
+ ARTIFACT_REPOSITORY: ${{ github.repository }}
83
+ ARTIFACT_PR_NUMBER: ${{ github.event.pull_request.number }}
84
+ ARTIFACT_HEAD_REVISION: ${{ github.event.pull_request.head.sha }}
85
+ ARTIFACT_RUN_ID: ${{ github.run_id }}
86
+ ARTIFACT_RUN_ATTEMPT: ${{ github.run_attempt }}
87
+ run: |
88
+ python ci/create_client_artifact.py --component cli \
89
+ --repository "$ARTIFACT_REPOSITORY" \
90
+ --pr-number "$ARTIFACT_PR_NUMBER" \
91
+ --head-revision "$ARTIFACT_HEAD_REVISION" \
92
+ --run-id "$ARTIFACT_RUN_ID" \
93
+ --run-attempt "$ARTIFACT_RUN_ATTEMPT"
94
+
76
95
  - name: Upload distributions
77
- uses: actions/upload-artifact@v4
96
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
78
97
  with:
79
98
  name: python-distributions
80
99
  path: dist/*
@@ -17,7 +17,7 @@ jobs:
17
17
 
18
18
  steps:
19
19
  - name: Run Release Please
20
- uses: googleapis/release-please-action@v4
20
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
21
21
  with:
22
22
  token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
23
23
  config-file: release-please-config.json
@@ -15,12 +15,12 @@ jobs:
15
15
 
16
16
  steps:
17
17
  - name: Checkout
18
- uses: actions/checkout@v4
18
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
19
19
 
20
20
  - name: Set up Python
21
- uses: actions/setup-python@v5
21
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
22
22
  with:
23
- python-version: "3.13"
23
+ python-version: "3.12"
24
24
  cache: pip
25
25
 
26
26
  - name: Install package
@@ -36,12 +36,12 @@ jobs:
36
36
 
37
37
  steps:
38
38
  - name: Checkout
39
- uses: actions/checkout@v4
39
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
40
40
 
41
41
  - name: Set up Python
42
- uses: actions/setup-python@v5
42
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
43
43
  with:
44
- python-version: "3.13"
44
+ python-version: "3.12"
45
45
  cache: pip
46
46
 
47
47
  - name: Install build tools
@@ -51,7 +51,7 @@ jobs:
51
51
  run: make package
52
52
 
53
53
  - name: Upload distributions
54
- uses: actions/upload-artifact@v4
54
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
55
55
  with:
56
56
  name: python-distributions
57
57
  path: dist/*
@@ -66,13 +66,13 @@ jobs:
66
66
 
67
67
  steps:
68
68
  - name: Download distributions
69
- uses: actions/download-artifact@v4
69
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
70
70
  with:
71
71
  name: python-distributions
72
72
  path: dist
73
73
 
74
74
  - name: Publish GitHub release notes
75
- uses: softprops/action-gh-release@v2
75
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
76
76
  with:
77
77
  generate_release_notes: true
78
78
  files: dist/*
@@ -89,13 +89,13 @@ jobs:
89
89
 
90
90
  steps:
91
91
  - name: Download distributions
92
- uses: actions/download-artifact@v4
92
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
93
93
  with:
94
94
  name: python-distributions
95
95
  path: dist
96
96
 
97
97
  - name: Publish distributions to PyPI
98
- uses: pypa/gh-action-pypi-publish@release/v1
98
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
99
99
 
100
100
  update-homebrew:
101
101
  name: Update Homebrew formula
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.6.1"
3
+ }
@@ -4,6 +4,27 @@ All notable changes to the LightNow CLI are documented here.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [1.6.1](https://github.com/lightnow-ai/lightnow-cli/compare/v1.6.0...v1.6.1) (2026-11-09)
8
+
9
+
10
+ ### Bug Fixes
11
+
12
+ * **cli:** use active context for default favorites ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
13
+ * **cli:** use active context for default favorites ([1ccba54](https://github.com/lightnow-ai/lightnow-cli/commit/1ccba543e34b236d449bdedeb644029c54f49f03))
14
+ * **cli:** use active context for default favorites ([#45](https://github.com/lightnow-ai/lightnow-cli/issues/45)) ([5399173](https://github.com/lightnow-ai/lightnow-cli/commit/539917321a8fbafedb05394841b5fadd0b5319e0))
15
+
16
+ ## [1.6.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.5.0...v1.6.0) (2026-10-09)
17
+
18
+
19
+ ### Features
20
+
21
+ * **auth:** support complete connection targets ([#39](https://github.com/lightnow-ai/lightnow-cli/issues/39)) ([699b09e](https://github.com/lightnow-ai/lightnow-cli/commit/699b09e70d1131a82667bb14a828d0234d19a75d))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **renovate:** constrain Python dependency names ([#22](https://github.com/lightnow-ai/lightnow-cli/issues/22)) ([a48bb79](https://github.com/lightnow-ai/lightnow-cli/commit/a48bb7935184d56f169c850d1fc478cde0c1667e))
27
+
7
28
  ## [1.5.0](https://github.com/lightnow-ai/lightnow-cli/compare/v1.4.0...v1.5.0) (2026-07-18)
8
29
 
9
30
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: lightnow-cli
3
- Version: 1.5.0
3
+ Version: 1.6.1
4
4
  Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
5
5
  Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
6
6
  Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
@@ -121,6 +121,47 @@ your session with:
121
121
  lightnow status
122
122
  ```
123
123
 
124
+ For an isolated LightNow environment, obtain a credential-free connection target
125
+ from its operator and select it explicitly:
126
+
127
+ ```bash
128
+ lightnow login --target ./connection-target.json
129
+ ```
130
+
131
+ The complete JSON schema is:
132
+
133
+ ```json
134
+ {
135
+ "schemaVersion": 1,
136
+ "issuer": "https://auth.example.test/realms/example",
137
+ "clientId": "lightnow-cli",
138
+ "registryApiUrl": "https://registry.example.test/v0.1",
139
+ "adminApiUrl": "https://admin.example.test/v0/portal"
140
+ }
141
+ ```
142
+
143
+ All five fields are required; additional fields and embedded credentials are
144
+ rejected. URLs must use HTTPS without credentials, query strings or fragments.
145
+ The environment operator provisions the public OAuth device-flow client; the CLI
146
+ does not register clients. Authentication remains at the product issuer, which
147
+ may broker an external identity provider through its browser flow.
148
+ The device request includes `openid profile email organization:*`; the operator
149
+ must assign the standard `organization` client scope so existing organization
150
+ memberships are available to the scoped APIs.
151
+
152
+ `--target` cannot be combined with `--local`, `--issuer` or `--client-id`. Custom
153
+ issuers require a complete target and cannot silently use production API defaults.
154
+ Normal `lightnow login` and `lightnow login --local` remain available.
155
+
156
+ The selected target and named session are saved only after device authorization
157
+ and userinfo verification succeed. OIDC discovery must identify the selected
158
+ issuer and expose credential-free HTTPS endpoints. Failed login preserves the previous connection.
159
+ A different environment or account returns to personal context; logging into the
160
+ same target and account preserves the selected organization. Organization selection,
161
+ profile requests and generated Local Proxy connections use the selected API URLs.
162
+ For acceptance runs, use a separate container user/home and explicit client and
163
+ proxy configuration paths so existing desktop connections are not modified.
164
+
124
165
  ## Find MCP Servers
125
166
 
126
167
  Search the LightNow Registry:
@@ -135,7 +176,7 @@ Show details for one server:
135
176
  lightnow info io.github.SonarSource/sonarqube-mcp-server
136
177
  ```
137
178
 
138
- Show your favorites:
179
+ Show favorites for the active personal or organization context:
139
180
 
140
181
  ```bash
141
182
  lightnow favorites
@@ -73,6 +73,47 @@ your session with:
73
73
  lightnow status
74
74
  ```
75
75
 
76
+ For an isolated LightNow environment, obtain a credential-free connection target
77
+ from its operator and select it explicitly:
78
+
79
+ ```bash
80
+ lightnow login --target ./connection-target.json
81
+ ```
82
+
83
+ The complete JSON schema is:
84
+
85
+ ```json
86
+ {
87
+ "schemaVersion": 1,
88
+ "issuer": "https://auth.example.test/realms/example",
89
+ "clientId": "lightnow-cli",
90
+ "registryApiUrl": "https://registry.example.test/v0.1",
91
+ "adminApiUrl": "https://admin.example.test/v0/portal"
92
+ }
93
+ ```
94
+
95
+ All five fields are required; additional fields and embedded credentials are
96
+ rejected. URLs must use HTTPS without credentials, query strings or fragments.
97
+ The environment operator provisions the public OAuth device-flow client; the CLI
98
+ does not register clients. Authentication remains at the product issuer, which
99
+ may broker an external identity provider through its browser flow.
100
+ The device request includes `openid profile email organization:*`; the operator
101
+ must assign the standard `organization` client scope so existing organization
102
+ memberships are available to the scoped APIs.
103
+
104
+ `--target` cannot be combined with `--local`, `--issuer` or `--client-id`. Custom
105
+ issuers require a complete target and cannot silently use production API defaults.
106
+ Normal `lightnow login` and `lightnow login --local` remain available.
107
+
108
+ The selected target and named session are saved only after device authorization
109
+ and userinfo verification succeed. OIDC discovery must identify the selected
110
+ issuer and expose credential-free HTTPS endpoints. Failed login preserves the previous connection.
111
+ A different environment or account returns to personal context; logging into the
112
+ same target and account preserves the selected organization. Organization selection,
113
+ profile requests and generated Local Proxy connections use the selected API URLs.
114
+ For acceptance runs, use a separate container user/home and explicit client and
115
+ proxy configuration paths so existing desktop connections are not modified.
116
+
76
117
  ## Find MCP Servers
77
118
 
78
119
  Search the LightNow Registry:
@@ -87,7 +128,7 @@ Show details for one server:
87
128
  lightnow info io.github.SonarSource/sonarqube-mcp-server
88
129
  ```
89
130
 
90
- Show your favorites:
131
+ Show favorites for the active personal or organization context:
91
132
 
92
133
  ```bash
93
134
  lightnow favorites
@@ -0,0 +1,126 @@
1
+ """Bind a PR build to its tested checkout and one universal Python wheel."""
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import tomllib
9
+ import zipfile
10
+ from email.parser import Parser
11
+ from pathlib import Path
12
+
13
+ SCHEMA_VERSION = "platform.lightnow.ai/feature-stack-client-artifact/v1"
14
+ MANIFEST_FILENAME = "feature-stack-client-artifact.json"
15
+ COMPONENTS = {"cli": "lightnow-cli", "proxy": "lightnow-proxy"}
16
+
17
+
18
+ def build_manifest(
19
+ root: Path,
20
+ component: str,
21
+ repository: str,
22
+ pr_number: int,
23
+ head_revision: str,
24
+ checkout_revision: str,
25
+ run_id: int,
26
+ run_attempt: int,
27
+ ) -> dict:
28
+ """Validate wheel contents and runner identity before creating provenance."""
29
+ package_name = COMPONENTS.get(component)
30
+ if package_name is None or repository != f"lightnow-ai/{package_name}":
31
+ raise ValueError("Component and repository do not match")
32
+ if any(value <= 0 for value in (pr_number, run_id, run_attempt)):
33
+ raise ValueError("PR number, run ID and run attempt must be positive")
34
+ if any(
35
+ re.fullmatch(r"[0-9a-f]{40}", value) is None
36
+ for value in (head_revision, checkout_revision)
37
+ ):
38
+ raise ValueError("Head and checkout revisions must be full Git SHA-1 values")
39
+
40
+ with (root / "pyproject.toml").open("rb") as project_file:
41
+ project = tomllib.load(project_file)["project"]
42
+ if project["name"] != package_name:
43
+ raise ValueError("Repository package metadata does not match component")
44
+
45
+ wheels = sorted((root / "dist").glob("*.whl"))
46
+ if len(wheels) != 1 or wheels[0].is_symlink() or not wheels[0].is_file():
47
+ raise ValueError("Exactly one regular wheel is required")
48
+ wheel = wheels[0]
49
+ wheel_package = package_name.replace("-", "_")
50
+ expected_filename = f"{wheel_package}-{project['version']}-py3-none-any.whl"
51
+ if wheel.name != expected_filename:
52
+ raise ValueError("Expected the repository's universal py3-none-any wheel")
53
+
54
+ prefix = f"{wheel_package}-{project['version']}.dist-info/"
55
+ with zipfile.ZipFile(wheel) as archive:
56
+ for name in (f"{prefix}METADATA", f"{prefix}WHEEL"):
57
+ if archive.namelist().count(name) != 1:
58
+ raise ValueError("Wheel metadata must be unique and match its filename")
59
+ metadata_files = [
60
+ name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
61
+ ]
62
+ if metadata_files != [f"{prefix}METADATA"]:
63
+ raise ValueError("Wheel must contain exactly one distribution's metadata")
64
+ metadata = Parser().parsestr(archive.read(f"{prefix}METADATA").decode("utf-8"))
65
+ wheel_metadata = Parser().parsestr(
66
+ archive.read(f"{prefix}WHEEL").decode("utf-8")
67
+ )
68
+ if metadata.get_all("Name") != [package_name] or metadata.get_all("Version") != [
69
+ project["version"]
70
+ ]:
71
+ raise ValueError("Wheel METADATA name/version do not match repository metadata")
72
+ if wheel_metadata.get_all("Tag") != ["py3-none-any"] or wheel_metadata.get_all(
73
+ "Root-Is-Purelib"
74
+ ) != ["true"]:
75
+ raise ValueError("Wheel must declare the universal pure-Python tag")
76
+
77
+ return {
78
+ "schemaVersion": SCHEMA_VERSION,
79
+ "component": component,
80
+ "repository": repository,
81
+ "packageName": metadata["Name"],
82
+ "packageVersion": metadata["Version"],
83
+ "filename": wheel.name,
84
+ "sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(),
85
+ "source": {
86
+ "kind": "pull-request",
87
+ "number": pr_number,
88
+ "headRevision": head_revision,
89
+ "checkoutRevision": checkout_revision,
90
+ "runId": run_id,
91
+ "runAttempt": run_attempt,
92
+ "workflowPath": ".github/workflows/ci.yml",
93
+ },
94
+ }
95
+
96
+
97
+ def main() -> None:
98
+ parser = argparse.ArgumentParser(description=__doc__)
99
+ parser.add_argument("--component", choices=tuple(COMPONENTS), required=True)
100
+ parser.add_argument("--repository", required=True)
101
+ parser.add_argument("--pr-number", type=int, required=True)
102
+ parser.add_argument("--head-revision", required=True)
103
+ parser.add_argument("--run-id", type=int, required=True)
104
+ parser.add_argument("--run-attempt", type=int, required=True)
105
+ args = parser.parse_args()
106
+ root = Path(__file__).resolve().parents[1]
107
+ checkout_revision = subprocess.check_output(
108
+ ["git", "rev-parse", "HEAD"], cwd=root, text=True
109
+ ).strip()
110
+ manifest = build_manifest(
111
+ root,
112
+ args.component,
113
+ args.repository,
114
+ args.pr_number,
115
+ args.head_revision,
116
+ checkout_revision,
117
+ args.run_id,
118
+ args.run_attempt,
119
+ )
120
+ (root / "dist" / MANIFEST_FILENAME).write_text(
121
+ json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
122
+ )
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,124 @@
1
+ """Offline provenance contract checks; no installation or external services."""
2
+
3
+ import hashlib
4
+ import tempfile
5
+ import unittest
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from create_client_artifact import build_manifest
10
+
11
+
12
+ class ClientArtifactTests(unittest.TestCase):
13
+ def setUp(self) -> None:
14
+ self.directory = tempfile.TemporaryDirectory()
15
+ self.addCleanup(self.directory.cleanup)
16
+ self.root = Path(self.directory.name)
17
+ (self.root / "dist").mkdir()
18
+ (self.root / "pyproject.toml").write_text(
19
+ '[project]\nname = "lightnow-cli"\nversion = "1.2.3"\n'
20
+ )
21
+ self.wheel = self.root / "dist/lightnow_cli-1.2.3-py3-none-any.whl"
22
+ self.write_wheel()
23
+
24
+ def write_wheel(
25
+ self,
26
+ name: str = "lightnow-cli",
27
+ version: str = "1.2.3",
28
+ tag: str = "py3-none-any",
29
+ ) -> None:
30
+ with zipfile.ZipFile(self.wheel, "w") as archive:
31
+ archive.writestr(
32
+ "lightnow_cli-1.2.3.dist-info/METADATA",
33
+ f"Metadata-Version: 2.3\nName: {name}\nVersion: {version}\n",
34
+ )
35
+ archive.writestr(
36
+ "lightnow_cli-1.2.3.dist-info/WHEEL",
37
+ f"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: {tag}\n",
38
+ )
39
+
40
+ def manifest(self, **overrides: object) -> dict:
41
+ arguments = dict(
42
+ root=self.root,
43
+ component="cli",
44
+ repository="lightnow-ai/lightnow-cli",
45
+ pr_number=42,
46
+ head_revision="a" * 40,
47
+ checkout_revision="b" * 40,
48
+ run_id=123,
49
+ run_attempt=2,
50
+ )
51
+ arguments.update(overrides)
52
+ return build_manifest(**arguments)
53
+
54
+ def test_exact_contract_keeps_head_and_tested_checkout_separate(self) -> None:
55
+ self.assertEqual(
56
+ self.manifest(),
57
+ {
58
+ "schemaVersion": "platform.lightnow.ai/feature-stack-client-artifact/v1",
59
+ "component": "cli",
60
+ "repository": "lightnow-ai/lightnow-cli",
61
+ "packageName": "lightnow-cli",
62
+ "packageVersion": "1.2.3",
63
+ "filename": self.wheel.name,
64
+ "sha256": hashlib.sha256(self.wheel.read_bytes()).hexdigest(),
65
+ "source": {
66
+ "kind": "pull-request",
67
+ "number": 42,
68
+ "headRevision": "a" * 40,
69
+ "checkoutRevision": "b" * 40,
70
+ "runId": 123,
71
+ "runAttempt": 2,
72
+ "workflowPath": ".github/workflows/ci.yml",
73
+ },
74
+ },
75
+ )
76
+
77
+ def test_rejects_unbound_identity(self) -> None:
78
+ for arguments in (
79
+ {"repository": "other/lightnow-cli"},
80
+ {"component": "proxy"},
81
+ {"head_revision": "a" * 7},
82
+ {"checkout_revision": "not-a-revision"},
83
+ {"pr_number": 0},
84
+ {"run_id": -1},
85
+ {"run_attempt": 0},
86
+ ):
87
+ with self.subTest(arguments=arguments), self.assertRaises(ValueError):
88
+ self.manifest(**arguments)
89
+
90
+ def test_rejects_metadata_or_platform_mismatch(self) -> None:
91
+ for arguments in (
92
+ {"name": "another-package"},
93
+ {"version": "9.0.0"},
94
+ {"tag": "cp312-cp312-linux_x86_64"},
95
+ ):
96
+ with self.subTest(arguments=arguments):
97
+ self.write_wheel(**arguments)
98
+ with self.assertRaises(ValueError):
99
+ self.manifest()
100
+
101
+ def test_rejects_ambiguous_missing_or_symlinked_wheel(self) -> None:
102
+ extra = self.wheel.with_name("another.whl")
103
+ extra.write_bytes(self.wheel.read_bytes())
104
+ with self.assertRaises(ValueError):
105
+ self.manifest()
106
+ extra.unlink()
107
+ self.wheel.unlink()
108
+ with self.assertRaises(ValueError):
109
+ self.manifest()
110
+ self.wheel.symlink_to(self.root / "outside.whl")
111
+ with self.assertRaises(ValueError):
112
+ self.manifest()
113
+
114
+ def test_rejects_duplicate_distribution_metadata(self) -> None:
115
+ with zipfile.ZipFile(self.wheel, "a") as archive:
116
+ archive.writestr(
117
+ "other-1.0.dist-info/METADATA", "Name: other\nVersion: 1.0\n"
118
+ )
119
+ with self.assertRaises(ValueError):
120
+ self.manifest()
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -101,3 +101,26 @@ tag. If the project does not exist yet, use PyPI's pending publisher flow for
101
101
  ```bash
102
102
  python -m twine check dist/*
103
103
  ```
104
+
105
+ ## PR distribution provenance
106
+
107
+ PR CI archives `feature-stack-client-artifact.json` alongside the wheel and
108
+ source distribution in `python-distributions`. The manifest uses
109
+ `platform.lightnow.ai/feature-stack-client-artifact/v1` and binds the universal
110
+ wheel SHA-256 and its actual METADATA name/version to the PR number, head
111
+ revision, tested checkout revision, workflow, run ID, and run attempt. The head
112
+ and tested merge checkout are separate fields. Consumers must verify these
113
+ bindings and the wheel checksum against the selected successful CI run before
114
+ installing a candidate; the manifest itself is build metadata, not a signature
115
+ or a runtime test result.
116
+
117
+ The generator uses only Python's standard library. After a clean package build,
118
+ run its focused offline contract checks with:
119
+
120
+ ```bash
121
+ python -m unittest discover -s ci -p 'test_*.py'
122
+ ```
123
+
124
+ Main CI does not fabricate PR metadata. Tagged release publishing is unchanged:
125
+ release consumers qualify the exact Git tag and published PyPI wheel separately.
126
+ The PR-only JSON never enters the existing PyPI release upload.
@@ -1,3 +1,3 @@
1
1
  """LightNow CLI - A command-line tool for managing MCP servers."""
2
2
 
3
- __version__ = "1.5.0" # x-release-please-version
3
+ __version__ = "1.6.1" # x-release-please-version