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
5 changes: 5 additions & 0 deletions .changeset/deterministic-rust-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@operatorstack/yield": patch
---

Make Python and Rust release receipts reproducible across protected publisher retries.
34 changes: 25 additions & 9 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
version: ${{ steps.release.outputs.version }}
dist_tag: ${{ steps.release.outputs.dist_tag }}
source_sha: ${{ steps.release.outputs.source_sha }}
source_epoch: ${{ steps.release.outputs.source_epoch }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand Down Expand Up @@ -75,6 +76,7 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "source_epoch=$(git show -s --format=%ct HEAD)" >> "$GITHUB_OUTPUT"

build:
needs: resolve
Expand Down Expand Up @@ -122,6 +124,7 @@ jobs:
- name: Build Python wheels
if: needs.resolve.outputs.channel == 'stable'
env:
SOURCE_DATE_EPOCH: ${{ needs.resolve.outputs.source_epoch }}
VERSION: ${{ needs.resolve.outputs.version }}
shell: bash
run: |
Expand All @@ -147,9 +150,7 @@ jobs:
name="$(sed -n 's/^name = "\([^"]*\)"/\1/p' "$directory/Cargo.toml" | head -n 1)"
cp "$directory/target/package/${name}-${VERSION}.crate" dist/crates/
done
(cd dist/packages/rust && cargo package --manifest-path yieldskill/Cargo.toml)
cp "dist/packages/rust/yieldskill/target/package/yieldskill-${VERSION}.crate" dist/crates/
test "$(find dist/crates -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ')" = 7
test "$(find dist/crates -maxdepth 1 -name '*.crate' | wc -l | tr -d ' ')" = 6
- name: Inspect npm tarballs
shell: bash
run: |
Expand Down Expand Up @@ -292,8 +293,6 @@ jobs:
permissions:
contents: read
id-token: write
env:
CRATES_BOOTSTRAP_TOKEN: ${{ secrets.CRATES_BOOTSTRAP_TOKEN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand All @@ -305,11 +304,10 @@ jobs:
path: dist/release-unit
- id: auth
name: Request a short-lived crates.io token
if: env.CRATES_BOOTSTRAP_TOKEN == ''
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish complete Rust release unit
env:
CARGO_REGISTRY_TOKEN: ${{ env.CRATES_BOOTSTRAP_TOKEN || steps.auth.outputs.token }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
VERSION: ${{ needs.resolve.outputs.version }}
shell: bash
run: |
Expand Down Expand Up @@ -347,9 +345,27 @@ jobs:
sleep 10
done

publish_if_missing dist/release-unit/rust/yieldskill
mkdir -p dist/crates-receipt
cp dist/release-unit/crates/*.crate dist/crates-receipt/
cargo package --manifest-path dist/release-unit/rust/yieldskill/Cargo.toml
cp "dist/release-unit/rust/yieldskill/target/package/yieldskill-${VERSION}.crate" dist/crates-receipt/
main_state="$(node packaging/crates-release.mjs status \
--version "$VERSION" \
--name yieldskill \
--archive "dist/crates-receipt/yieldskill-${VERSION}.crate")"
if [[ "$main_state" == matched ]]; then
echo "yieldskill@${VERSION} already exists with the publisher receipt checksum"
else
test "$main_state" = missing
cargo publish --manifest-path dist/release-unit/rust/yieldskill/Cargo.toml
fi
node packaging/crates-release.mjs verify \
--version "$VERSION" \
--archives dist/release-unit/crates \
--archives dist/crates-receipt \
--attempts 18 \
--delay-ms 10000
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}
path: dist/crates-receipt/
if-no-files-found: error
4 changes: 3 additions & 1 deletion .github/workflows/release-finalize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ jobs:
test -n "$run_id"
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \
--name "packages-${version}-${SOURCE_SHA}" --dir "$RUNNER_TEMP/release-unit"
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \
--name "crates-${version}-${SOURCE_SHA}" --dir "$RUNNER_TEMP/crates-receipt"
for package in \
@operatorstack/yield \
@operatorstack/yield-darwin-amd64 @operatorstack/yield-darwin-arm64 \
Expand All @@ -106,7 +108,7 @@ jobs:
--delay-ms 10000
node packaging/crates-release.mjs verify \
--version "$version" \
--archives "$RUNNER_TEMP/release-unit/crates" \
--archives "$RUNNER_TEMP/crates-receipt" \
--attempts 3 \
--delay-ms 10000
test "$(git rev-list -n 1 "$TAG")" = "$SOURCE_SHA"
Expand Down
2 changes: 0 additions & 2 deletions packaging/assemble.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ async function assembleRust({ version, binaries, output }, records) {
await writeFile(join(main, "Cargo.toml"), cargo);
await mkdir(join(main, "src/bin"), { recursive: true });
await cp(join(root, "packaging/rust-launcher.rs"), join(main, "src/bin/yskill.rs"));
await mkdir(join(rust, ".cargo"), { recursive: true });
await writeFile(join(rust, ".cargo/config.toml"), `[patch.crates-io]\n${targets.map((target) => `${rustPackage(target)} = { path = "runtime/${target.id}" }`).join("\n")}\n`);
}

export async function assemble(options) {
Expand Down
7 changes: 2 additions & 5 deletions packaging/assemble.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { assemble, isPackageVersion } from "./assemble.mjs";
Expand Down Expand Up @@ -100,8 +100,5 @@ test("assembles one public npm package and six matching npm and Python runtimes"
assert.match(rustReadme, /crates\.io\/crates\/yieldskill/);
assert.doesNotMatch(rustReadme, /npmjs\.com|pypi\.org/);
assert.match(await readFile(join(rustMain, "LICENSE"), "utf8"), /MIT License/);
const rustPatch = await readFile(join(output, "rust/.cargo/config.toml"), "utf8");
for (const target of targets) {
assert.match(rustPatch, new RegExp(`${rustPackage(target)} = \\{ path = "runtime/${target.id}" \\}`));
}
await assert.rejects(access(join(output, "rust/.cargo/config.toml")), { code: "ENOENT" });
});
6 changes: 4 additions & 2 deletions scripts/check-release-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ".
expect(raw["npm-publish.yml"].includes("pypa/gh-action-pypi-publish@"), "PyPI publishing must use the trusted-publishing action");
expect(raw["npm-publish.yml"].includes("rust-lang/crates-io-auth-action@"), "crates.io publishing must use the trusted-publishing action");
expect(raw["npm-publish.yml"].includes("chmod 0644 dist/packages/rust/runtime/*/runtime/*"), "Rust archives must normalize embedded runtime modes before artifact transport");
expect(raw["npm-publish.yml"].includes("SOURCE_DATE_EPOCH"), "Python wheels must bind timestamps to the immutable source revision");
expect(raw["npm-publish.yml"].includes("name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}"), "the crates publisher must upload an exact post-dependency receipt");
expect(raw["npm-publish.yml"].indexOf("rust/runtime/*") < raw["npm-publish.yml"].indexOf("rust/yieldskill"), "Rust runtime crates must publish before the SDK crate");
expect(!raw["npm-publish.yml"].includes("skip-existing"), "PyPI retries must verify hashes instead of blindly skipping existing files");

Expand All @@ -95,8 +97,8 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ".
expect(raw["release-finalize.yml"].includes("npm-publish.yml"), "finalization must bind the combined publisher receipt");
expect(raw["release-finalize.yml"].includes("pypi-release.mjs verify"), "finalization must verify the PyPI wheel hashes");
expect(raw["release-finalize.yml"].includes("crates-release.mjs verify"), "finalization must verify the crates.io package hashes");
const bootstrapSecretUses = Object.values(raw).reduce((count, text) => count + (text.match(/secrets\.CRATES_BOOTSTRAP_TOKEN/g) ?? []).length, 0);
expect(bootstrapSecretUses === 1, "the one-time crates.io bootstrap token must be scoped only to the protected publisher job");
expect(raw["release-finalize.yml"].includes("--name \"crates-${version}-${SOURCE_SHA}\""), "finalization must consume the publisher-produced crates receipt");
expect(!Object.values(raw).some((text) => text.includes("CRATES_BOOTSTRAP_TOKEN")), "crates.io publishing must not use a bootstrap token");
for (const [name, text] of Object.entries(raw)) {
expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|secrets\.(npm|pypi)|password:/i.test(text), `${name}: long-lived registry credentials are forbidden`);
}
Expand Down