Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ on:
required: false
type: boolean
default: false
pypi_verify_only:
description: Verify an already submitted PyPI release without republishing
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
Expand Down Expand Up @@ -122,7 +127,17 @@ jobs:
npm install --prefix "$consumer" --ignore-scripts --prefer-online "$package_spec"
cd "$consumer"
node --input-type=module -e 'await import("@clone-ai/prompt-prediction"); await import("@clone-ai/prompt-prediction/server")'
npm audit signatures
# Registry metadata can become visible before its attestation endpoint.
# Wait only for E404; an invalid or missing signature still fails closed.
for signature_attempt in {1..20}; do
if npm audit signatures --json > "$RUNNER_TEMP/npm-signatures.json"; then
cat "$RUNNER_TEMP/npm-signatures.json"
break
fi
node -e 'const result = require(process.argv[1]); process.exit(result.error?.code === "E404" ? 0 : 1)' "$RUNNER_TEMP/npm-signatures.json"
[[ "$signature_attempt" -lt 20 ]] || { cat "$RUNNER_TEMP/npm-signatures.json"; exit 1; }
sleep 15
done
registry_tarball=$(npm pack "$package_spec" --pack-destination "$RUNNER_TEMP" --json | node -e 'let s=""; process.stdin.on("data",c=>s+=c).on("end",()=>console.log(JSON.parse(s)[0].filename))')
cmp "$RUNNER_TEMP/$registry_tarball" "$GITHUB_WORKSPACE/release/clone-ai-prompt-prediction-$version.tgz"
pypi:
Expand Down Expand Up @@ -151,12 +166,42 @@ jobs:
gh release download "$RELEASE_TAG" --pattern '*.whl' --pattern '*.tar.gz' --dir released-python
for file in dist/*; do cmp "$file" "released-python/$(basename "$file")"; done
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
if: ${{ !inputs.pypi_verify_only }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.11'
- name: Verify anonymous PyPI installation
run: |
set -euo pipefail
version=$(python -c 'from pathlib import Path; print(next(Path("dist").glob("*.whl")).name.split("-")[1])')
# Do not rerun a successful upload when the public index is catching up.
for pypi_attempt in {1..20}; do
if python - "$version" <<'PYCODE'
import hashlib, json, sys, urllib.error, urllib.request
from pathlib import Path
try:
with urllib.request.urlopen("https://pypi.org/pypi/clone-sdk/" + sys.argv[1] + "/json", timeout=15) as response:
metadata = json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
raise SystemExit(3)
raise
wheel = next(Path("dist").glob("*.whl"))
public = next(item for item in metadata["urls"] if item["filename"] == wheel.name)
expected = hashlib.sha256(wheel.read_bytes()).hexdigest()
assert not public["yanked"] and public["digests"]["sha256"] == expected
request = urllib.request.Request("https://pypi.org/simple/clone-sdk/", headers={"Accept": "application/vnd.pypi.simple.v1+json"})
with urllib.request.urlopen(request, timeout=15) as response:
index = json.load(response)
indexed = next((item for item in index["files"] if item["filename"] == wheel.name), None)
if indexed is None:
raise SystemExit(3)
assert not indexed.get("yanked") and indexed["hashes"]["sha256"] == expected
PYCODE
then break; else lookup_status=$?; fi
[[ "$lookup_status" -eq 3 && "$pypi_attempt" -lt 20 ]] || exit 1
sleep 15
done
python -m venv "$RUNNER_TEMP/pypi-consumer"
"$RUNNER_TEMP/pypi-consumer/bin/pip" install "clone-sdk==$version"
cd "$RUNNER_TEMP"
Expand Down
73 changes: 73 additions & 0 deletions tests/scripts/provenance-wait.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { test } from 'node:test';

const workflow = await readFile(new URL('../../.github/workflows/publish.yml', import.meta.url), 'utf8');
const loops = {
npm: workflow.match(/ for signature_attempt in \{1\.\.20\}; do\n[\s\S]*? done/)[0],
python: workflow.match(/ for pypi_attempt in \{1\.\.20\}; do\n[\s\S]*? done/)[0],
};

async function verify(t, code, failures, tool = 'npm') {
const directory = await mkdtemp(join(tmpdir(), 'clone-provenance-wait-'));
t.after(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, tool), `#!/bin/bash
count=0
[[ ! -f "$RUNNER_TEMP/count" ]] || read -r count < "$RUNNER_TEMP/count"
count=$((count + 1))
printf '%s' "$count" > "$RUNNER_TEMP/count"
if [[ "$count" -le "$PROBE_FAILURES" ]]; then
if [[ "$PROBE_TOOL" == python && "$PROBE_CODE" == E404 ]]; then exit 3; fi
printf '{"error":{"code":"%s"}}' "$PROBE_CODE"
exit 1
fi
printf '{"verified":1}'
`, { mode: 0o700 });
await writeFile(join(directory, 'sleep'), '#!/bin/bash\nexit 0\n', { mode: 0o700 });
const loop = loops[tool].split('\n').map(line => line.replace(/^ /, '')).join('\n');
const result = spawnSync('bash', ['-euc', loop], { encoding: 'utf8',
env: { ...process.env, RUNNER_TEMP: directory,
PATH: directory + ':' + dirname(process.execPath) + ':' + process.env.PATH,
PROBE_CODE: code, PROBE_FAILURES: String(failures), PROBE_TOOL: tool, version: '0.2.1' } });
return { ...result, attempts: Number(await readFile(join(directory, 'count'), 'utf8')) };
}

test('provenance verification recovers from registry E404 without republishing', async t => {
const result = await verify(t, 'E404', 1);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.attempts, 2);
assert.match(result.stdout, /verified/);
});

test('an invalid signature fails immediately', async t => {
const result = await verify(t, 'EINTEGRITY', 1);
assert.equal(result.status, 1);
assert.equal(result.attempts, 1);
});

test('a persistently unavailable attestation fails at the bound', async t => {
const result = await verify(t, 'E404', 100);
assert.equal(result.status, 1);
assert.equal(result.attempts, 20);
});

test('PyPI verification waits for the uploaded version to reach the index', async t => {
const result = await verify(t, 'E404', 1, 'python');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.attempts, 2);
});

test('PyPI verification does not retry an integrity failure', async t => {
const result = await verify(t, 'EINTEGRITY', 1, 'python');
assert.equal(result.status, 1);
assert.equal(result.attempts, 1);
});

test('a persistently unavailable PyPI version fails at the bound', async t => {
const result = await verify(t, 'E404', 100, 'python');
assert.equal(result.status, 1);
assert.equal(result.attempts, 20);
});
Loading