diff --git a/.changeset/deterministic-rust-release.md b/.changeset/deterministic-rust-release.md new file mode 100644 index 0000000..d469709 --- /dev/null +++ b/.changeset/deterministic-rust-release.md @@ -0,0 +1,5 @@ +--- +"@operatorstack/yield": patch +--- + +Make Python and Rust release receipts reproducible across protected publisher retries. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b121d4f..485d6af 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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: @@ -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 @@ -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: | @@ -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: | @@ -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: @@ -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: | @@ -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 diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 2ad013d..a460fbb 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -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 \ @@ -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" diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs index 852b41a..28939c9 100644 --- a/packaging/assemble.mjs +++ b/packaging/assemble.mjs @@ -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) { diff --git a/packaging/assemble.test.mjs b/packaging/assemble.test.mjs index f588f9e..ed9b7b0 100644 --- a/packaging/assemble.test.mjs +++ b/packaging/assemble.test.mjs @@ -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"; @@ -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" }); }); diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 058aa61..06a7cc9 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -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"); @@ -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`); }