Skip to content

Commit 0dd4581

Browse files
authored
changes (#13)
* change * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * boo
1 parent 412aeb8 commit 0dd4581

File tree

10 files changed

+8937
-821
lines changed

10 files changed

+8937
-821
lines changed

.claude/settings.local.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"WebSearch",
5+
"Bash(find:*)",
6+
"Bash(pnpm build:*)",
7+
"Bash(pnpm install:*)",
8+
"WebFetch(domain:raw.githubusercontent.com)",
9+
"Bash(pip index versions:*)"
10+
],
11+
"deny": [],
12+
"ask": []
13+
}
14+
}

actions/publish-pypi/action.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ description: "Publish packages to PyPI"
44

55
inputs:
66
user:
7-
description: "the username for PyPI"
8-
required: true
7+
description: "the username for PyPI (optional when using OIDC)"
8+
required: false
99
default: "__token__"
1010
passwords:
11-
description: "the passwords for each package. Each package should be on a new line. Passwords should be in the format `package_name:password`"
12-
required: true
11+
description: "the passwords for each package. Each package should be on a new line. Passwords should be in the format `package_name:password` (optional when using OIDC)"
12+
required: false
13+
use-oidc:
14+
description: "Use OIDC trusted publishing for authentication"
15+
required: false
16+
default: "false"
17+
repository-url:
18+
description: "Repository URL (defaults to https://upload.pypi.org/legacy/)"
19+
required: false
20+
default: ""
1321

1422
runs:
1523
using: "node20"

actions/publish-pypi/dist/index.js

Lines changed: 7991 additions & 733 deletions
Large diffs are not rendered by default.

packages/publish-pypi/action.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ description: "Publish packages to PyPI"
44

55
inputs:
66
user:
7-
description: "the username for PyPI"
8-
required: true
7+
description: "the username for PyPI (optional when using OIDC)"
8+
required: false
99
default: "__token__"
1010
passwords:
11-
description: "the passwords for each package. Each package should be on a new line. Passwords should be in the format `package_name:password`"
12-
required: true
11+
description: "the passwords for each package. Each package should be on a new line. Passwords should be in the format `package_name:password` (optional when using OIDC)"
12+
required: false
13+
use-oidc:
14+
description: "Use OIDC trusted publishing for authentication"
15+
required: false
16+
default: "false"
17+
repository-url:
18+
description: "Repository URL (defaults to https://upload.pypi.org/legacy/)"
19+
required: false
20+
default: ""
1321

1422
runs:
1523
using: "node20"
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { info, warning, setFailed } from "@actions/core";
2+
import { exec, getExecOutput } from "@actions/exec";
3+
import { promises as fs } from "fs";
4+
import { join, basename } from "path";
5+
import { glob } from "glob";
6+
7+
export interface AttestationOptions {
8+
distDir: string;
9+
oidcToken?: string;
10+
}
11+
12+
export async function generateAttestations(
13+
options: AttestationOptions
14+
): Promise<boolean> {
15+
const { distDir, oidcToken } = options;
16+
17+
try {
18+
info("Generating attestations for distributions...");
19+
20+
const distFiles = await glob(join(distDir, "*"), {
21+
absolute: true,
22+
});
23+
24+
const distributions = distFiles.filter((file) => {
25+
const name = basename(file);
26+
return (
27+
(name.endsWith(".whl") || name.endsWith(".tar.gz")) &&
28+
!name.endsWith(".publish.attestation")
29+
);
30+
});
31+
32+
if (distributions.length === 0) {
33+
warning("No distribution files found to attest.");
34+
return false;
35+
}
36+
37+
info(`Found ${distributions.length} distribution(s) to attest.`);
38+
39+
const existingAttestations = distFiles.filter((file) =>
40+
file.endsWith(".publish.attestation")
41+
);
42+
43+
if (existingAttestations.length > 0) {
44+
setFailed(
45+
"Attestation files already exist in the dist directory: " +
46+
existingAttestations.map((f) => basename(f)).join(", ") +
47+
"\nRemove them before continuing."
48+
);
49+
return false;
50+
}
51+
52+
const attestScript = `
53+
import sys
54+
import json
55+
import os
56+
from pathlib import Path
57+
from pypi_attestations import Attestation, Distribution
58+
from sigstore.oidc import IdentityError, IdentityToken, detect_credential
59+
from sigstore.sign import SigningContext
60+
61+
def get_identity_token() -> IdentityToken:
62+
# Will raise sigstore.oidc.IdentityError if it fails to get the token
63+
# from the environment or if the token is malformed.
64+
# NOTE: audience is always sigstore.
65+
oidc_token = detect_credential()
66+
if oidc_token is None:
67+
raise IdentityError('Attempted to discover OIDC in broken environment')
68+
return IdentityToken(oidc_token)
69+
70+
def generate_attestations(dist_files):
71+
errors = []
72+
73+
try:
74+
identity = get_identity_token()
75+
except (IdentityError, Exception) as e:
76+
print(f"Failed to get OIDC credential: {e}", file=sys.stderr)
77+
return False
78+
79+
# Use SigningContext.production().signer() as a context manager
80+
with SigningContext.production().signer(identity, cache=True) as signer:
81+
for dist_file in dist_files:
82+
dist_path = Path(dist_file)
83+
84+
try:
85+
print(f"Attesting {dist_path.name}...")
86+
87+
dist = Distribution.from_file(dist_path)
88+
attestation = Attestation.sign(signer, dist)
89+
90+
attestation_path = dist_path.with_suffix(dist_path.suffix + ".publish.attestation")
91+
attestation_path.write_text(attestation.model_dump_json())
92+
93+
print(f"Created attestation: {attestation_path.name}")
94+
95+
except Exception as e:
96+
errors.append(f"Error attesting {dist_path.name}: {e}")
97+
print(f"Error attesting {dist_path.name}: {e}", file=sys.stderr)
98+
99+
if errors:
100+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
101+
if summary_path:
102+
with open(summary_path, "a") as f:
103+
f.write("## Attestation Errors\\n\\n")
104+
for error in errors:
105+
f.write(f"- {error}\\n")
106+
return False
107+
108+
return True
109+
110+
if __name__ == "__main__":
111+
import json
112+
dist_files = json.loads(sys.argv[1])
113+
success = generate_attestations(dist_files)
114+
sys.exit(0 if success else 1)
115+
`;
116+
117+
await fs.writeFile("_action_temp/generate_attestations.py", attestScript);
118+
119+
//@ts-ignore
120+
const env: Record<string, string> = {
121+
...process.env,
122+
};
123+
124+
if (oidcToken) {
125+
env.PYPI_ATTESTATIONS_TOKEN = oidcToken;
126+
}
127+
128+
const result = await exec(
129+
"python",
130+
["_action_temp/generate_attestations.py", JSON.stringify(distributions)],
131+
{
132+
env,
133+
ignoreReturnCode: true,
134+
}
135+
);
136+
137+
if (result !== 0) {
138+
setFailed("Failed to generate attestations.");
139+
return false;
140+
}
141+
142+
const attestationFiles = await glob(
143+
join(distDir, "*.publish.attestation"),
144+
{
145+
absolute: true,
146+
}
147+
);
148+
149+
info(`Successfully generated ${attestationFiles.length} attestation(s).`);
150+
151+
for (const file of attestationFiles) {
152+
info(` - ${basename(file)}`);
153+
}
154+
155+
return true;
156+
} catch (e: any) {
157+
setFailed(`Error during attestation generation: ${e.message}`);
158+
return false;
159+
}
160+
}
161+
162+
export async function installAttestationDependencies(): Promise<void> {
163+
info("Installing attestation dependencies...");
164+
165+
await exec("pip", [
166+
"install",
167+
"--user",
168+
"--upgrade",
169+
"--no-cache-dir",
170+
"pypi-attestations>=0.0.27",
171+
"sigstore>=3.6.5",
172+
]);
173+
}
174+
175+
export async function verifyAttestations(distDir: string): Promise<boolean> {
176+
try {
177+
const distFiles = await glob(join(distDir, "*"), {
178+
absolute: true,
179+
});
180+
181+
const distributions = distFiles.filter((file) => {
182+
const name = basename(file);
183+
return (
184+
(name.endsWith(".whl") || name.endsWith(".tar.gz")) &&
185+
!name.endsWith(".publish.attestation")
186+
);
187+
});
188+
189+
const attestations = distFiles.filter((file) =>
190+
file.endsWith(".publish.attestation")
191+
);
192+
193+
if (distributions.length !== attestations.length) {
194+
warning(
195+
`Mismatch between distributions (${distributions.length}) ` +
196+
`and attestations (${attestations.length}). ` +
197+
`Some distributions may not have attestations.`
198+
);
199+
}
200+
201+
for (const dist of distributions) {
202+
const attestationPath = `${dist}.publish.attestation`;
203+
const hasAttestation = attestations.includes(attestationPath);
204+
205+
if (!hasAttestation) {
206+
warning(`Missing attestation for: ${basename(dist)}`);
207+
}
208+
}
209+
210+
return true;
211+
} catch (e: any) {
212+
warning(`Failed to verify attestations: ${e.message}`);
213+
return false;
214+
}
215+
}

0 commit comments

Comments
 (0)