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.
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/ci.yml +25 -6
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/release-please.yml +1 -1
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.github/workflows/release.yml +11 -11
- lightnow_cli-1.6.1/.release-please-manifest.json +3 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/CHANGELOG.md +21 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/PKG-INFO +44 -3
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/README.md +42 -1
- lightnow_cli-1.6.1/ci/create_client_artifact.py +126 -0
- lightnow_cli-1.6.1/ci/test_client_artifact.py +124 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/docs/release.md +23 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/auth.py +70 -24
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/query.py +1 -1
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/config.py +217 -7
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/main.py +4 -4
- lightnow_cli-1.6.1/lightnow_cli/target.py +102 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/pyproject.toml +1 -1
- lightnow_cli-1.6.1/renovate.json +47 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_auth_flow.py +4 -2
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_cli.py +73 -30
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_config.py +4 -2
- lightnow_cli-1.6.1/tests/test_connection_target.py +535 -0
- lightnow_cli-1.5.0/.release-please-manifest.json +0 -3
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/.gitignore +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/LICENSE +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/Makefile +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/SECURITY.md +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/docs/development.md +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/README.md +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/docs.md +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/openapi.json +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/examples/server.json +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/client.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/context.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/integrations.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/runner.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/updates.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/updates.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/release-please-config.json +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/__init__.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_authenticated_http.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_client.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_context.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_integrations.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_tls.py +0 -0
- {lightnow_cli-1.5.0 → lightnow_cli-1.6.1}/tests/test_updates.py +0 -0
- {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@
|
|
26
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
27
27
|
|
|
28
28
|
- name: Set up Python
|
|
29
|
-
uses: actions/setup-python@
|
|
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@
|
|
62
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
63
63
|
|
|
64
64
|
- name: Set up Python
|
|
65
|
-
uses: actions/setup-python@
|
|
65
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
|
66
66
|
with:
|
|
67
|
-
python-version: "3.
|
|
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@
|
|
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@
|
|
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@
|
|
18
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
19
19
|
|
|
20
20
|
- name: Set up Python
|
|
21
|
-
uses: actions/setup-python@
|
|
21
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
|
22
22
|
with:
|
|
23
|
-
python-version: "3.
|
|
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@
|
|
39
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
40
40
|
|
|
41
41
|
- name: Set up Python
|
|
42
|
-
uses: actions/setup-python@
|
|
42
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
|
43
43
|
with:
|
|
44
|
-
python-version: "3.
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: lightnow-cli
|
|
3
|
-
Version: 1.
|
|
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
|
|
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
|
|
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.
|