From f9b56c4aff2bb3668bf1e0fe3f62402ace2e69e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:04:32 +0000 Subject: [PATCH 1/2] ci(release): attest SLSA provenance over the release manifest's artifacts build-release writes an in-toto Statement v1 with a SLSA Provenance v1 predicate whose subjects are every artifact SHA256SUMS.txt lists, signs it with cosign keyless under the pipeline's CircleCI OIDC identity, verifies the attestation against built artifacts and against the manifest's lines, and publishes the statement and bundle beside the manifest. Co-Authored-By: jason.han --- .circleci/config.yml | 46 +++++ .github/workflows/pr.yml | 3 + .../unreleased/release-provenance.security.md | 1 + docs/project/releasing.md | 69 ++++++- scripts/release-provenance-test.py | 175 ++++++++++++++++ scripts/release-provenance.py | 190 ++++++++++++++++++ 6 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 changes/unreleased/release-provenance.security.md create mode 100755 scripts/release-provenance-test.py create mode 100755 scripts/release-provenance.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 111dae83d7..1373e6a794 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -230,6 +230,10 @@ jobs: name: Check changelog fragments command: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - run: + name: Check the release provenance writer + command: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - run: name: Check the compliance census hook @@ -1650,6 +1654,46 @@ jobs: | base64 -d | openssl x509 -inform DER -noout -text \ | grep -A1 -E '1\.3\.6\.1\.4\.1\.57264\.1\.8:|URI:' || true + # SLSA provenance over every artifact the manifest lists, signed under the + # same CircleCI identity as the manifest; the bundle carries the statement. + - run: + name: Attest the release's SLSA provenance with cosign keyless + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + python3 scripts/release-provenance.py \ + --manifest dist/SHA256SUMS.txt --out dist/provenance.intoto.json + SIGSTORE_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "sigstore"}')" + export SIGSTORE_ID_TOKEN + + cd dist + cosign attest-blob SHA256SUMS.txt \ + --statement provenance.intoto.json \ + --type slsaprovenance1 \ + --oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --bundle provenance.intoto.json.bundle \ + --use-signing-config=false \ + --yes + + # Verifies the attestation against a published artifact, so a statement + # whose subjects do not name the release's bytes fails the release here. + - run: + name: Verify the provenance names the artifacts under the identity clients pin + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + cd dist + for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 opensysml-*-py3-none-any.whl; do + cosign verify-blob-attestation "$artifact" \ + --bundle provenance.intoto.json.bundle \ + --type slsaprovenance1 \ + --certificate-oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --certificate-identity-regexp "^https://circleci\\.com/api/v2/projects/${CIRCLE_PROJECT_ID}/pipeline-definitions/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + echo "ok: provenance names $artifact" + done + # Every manifest line must be a subject, and nothing else may be. + diff <(sed 's/^\([0-9a-f]*\) \(.*\)$/\2 \1/' SHA256SUMS.txt | sort) \ + <(jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d \ + | jq -r '.subject[] | "\(.name) \(.digest.sha256)"' | sort) + # An artifact whose version disagrees with its tag looks identical on the # release page. The host-platform builds are asked what they report; the @@ -1865,6 +1909,8 @@ jobs: # The signature over that manifest, which the Python client verifies # before it trusts a digest from it. mv dist/SHA256SUMS.txt.bundle dist/release/ + # The SLSA provenance statement over those assets and its signature. + mv dist/provenance.intoto.json dist/provenance.intoto.json.bundle dist/release/ echo "Release artifacts:" ls -la dist/release/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c5a372af4b..4c5d56b558 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -863,6 +863,9 @@ jobs: - name: Check changelog fragments run: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - name: Check the release provenance writer + run: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - name: Check the compliance census hook run: python3 scripts/mkdocs_census-test.py diff --git a/changes/unreleased/release-provenance.security.md b/changes/unreleased/release-provenance.security.md new file mode 100644 index 0000000000..f2bd430d85 --- /dev/null +++ b/changes/unreleased/release-provenance.security.md @@ -0,0 +1 @@ +- **Releases carry signed SLSA provenance.** `build-release` writes an in-toto statement whose subjects are every artifact `SHA256SUMS.txt` lists and whose SLSA Provenance v1 predicate records the repository, tag, commit and CircleCI job that built them (`scripts/release-provenance.py`), attests it with cosign keyless under the same CircleCI identity that signs the manifest, verifies the attestation against the built artifacts before anything is stored, and publishes `provenance.intoto.json` and `provenance.intoto.json.bundle` beside the manifest. A download is checked with `cosign verify-blob-attestation --bundle provenance.intoto.json.bundle --type slsaprovenance1` and the pipeline's identity; see "The release provenance" in `docs/project/releasing.md` for what the statement does and does not claim. The clients keep verifying the signed manifest and are unchanged. diff --git a/docs/project/releasing.md b/docs/project/releasing.md index 5af497b573..ee0380501b 100644 --- a/docs/project/releasing.md +++ b/docs/project/releasing.md @@ -167,7 +167,12 @@ does a tag whose version `client/python/opensysml/_version.py` does not declare. - the Python client's distribution, `opensysml--py3-none-any.whl` and `opensysml-.tar.gz`, built by `build-python-package` and the same files `publish-pypi` uploads (see [Releasing opensysml to PyPI](#releasing-opensysml-to-pypi)); -- `SHA256SUMS.txt` over every archive, the wheel and every `sysml-grpc` binary. +- `SHA256SUMS.txt` over every archive, the wheel and every `sysml-grpc` binary, + with its cosign signature `SHA256SUMS.txt.bundle` (see + [The signed checksum manifest](#the-signed-checksum-manifest)); +- `provenance.intoto.json`, the SLSA provenance statement naming every artifact + the manifest lists, and `provenance.intoto.json.bundle`, its cosign + attestation (see [The release provenance](#the-release-provenance)). Platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64. @@ -241,6 +246,17 @@ one alongside it). A missing bundle means `build-release` did not sign — re-run the tag's workflow rather than pinning around it. + The provenance is checked the same way, against the downloaded archive + rather than the manifest: + + ```bash + curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/provenance.intoto.json.bundle + cosign verify-blob-attestation opensysml-linux-amd64.tar.gz \ + --bundle provenance.intoto.json.bundle --type slsaprovenance1 \ + --certificate-oidc-issuer https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62 \ + --certificate-identity-regexp '^https://circleci\.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d/pipeline-definitions/[0-9a-f-]+$' + ``` + Then install the Python client the release published, from the index rather than the source tree, and run it against the release's own `sysml-grpc` — the pairing a user who pins one version gets (see @@ -345,6 +361,57 @@ installed. The `.sha256` served beside a binary is still never a reason to trust it — same origin as the binary — and remains behind `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD`. +### The release provenance + +`build-release` also writes a [SLSA provenance](https://slsa.dev/spec/v1.0/provenance) +statement over the same artifacts and signs it under the same identity. +`scripts/release-provenance.py` reads `dist/SHA256SUMS.txt` and writes +`dist/provenance.intoto.json`: an in-toto Statement v1 whose subjects are every +artifact the manifest lists, with the manifest's digest, and whose predicate +(`https://slsa.dev/provenance/v1`) records what built them — the repository +and tag (`externalParameters`), the commit the tag resolved to +(`resolvedDependencies`), the CircleCI organization, project and workflow +(`internalParameters`), the project as the builder (`runDetails.builder.id`) +and the job's URL as the invocation. The build type, +`https://github.com/Open-MBEE/OpenSysML/.circleci/build-release/v1`, names this +repository's own job; its version moves when what the job does changes. The +script refuses to write a statement from an empty or malformed manifest, or +without every one of the CircleCI variables it describes the build from, so a +vaguer statement is never published in place of the intended one. + +`cosign attest-blob --statement` then signs that statement as it stands — every +subject kept, nothing re-derived — into a DSSE envelope in a sigstore bundle, +`provenance.intoto.json.bundle`, keylessly under the job's CircleCI OIDC +identity, exactly as the manifest is signed. The job verifies its own +attestation against three published artifacts (a bundle archive, a `sysml-grpc` +binary and the wheel) under the identity the clients pin, and checks that the +subjects are the manifest's lines, no more and no fewer, before anything is +stored; `publish-github-release` uploads the statement and the bundle beside +`SHA256SUMS.txt`. + +What this is, and is not. The statement is produced by the build that produced +the artifacts, on CircleCI's hosted runners, and signed with an identity only +that pipeline can hold, so a verifier learns which repository, tag and commit a +downloaded file was built from and which job built it — SLSA Build L2. It is not +Build L3: CircleCI does not itself issue provenance, so the statement is +generated by the job it describes rather than by the platform outside it, and +nothing stops a change to `.circleci/config.yml` from changing what is written. +That is why the buildType is versioned and why the trust anchor stays the +certificate identity: a statement signed by anything but this project's +pipeline verifies as nothing. A provenance workflow that hashes downloaded +assets on another platform would attest that platform's download, not this +build, and is not what this is. + +The unsigned `provenance.intoto.json` is a convenience for reading; the +authoritative statement is the bundle's payload: + +```bash +jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d | jq . +``` + +The Python and Node clients keep reading the signed manifest, not the +provenance; nothing in them changes. + ### Windows Authenticode signing The policy users see is the [Code signing policy](../../README.md#code-signing-policy) diff --git a/scripts/release-provenance-test.py b/scripts/release-provenance-test.py new file mode 100755 index 0000000000..540bd4e5c0 --- /dev/null +++ b/scripts/release-provenance-test.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Tests for scripts/release-provenance.py. Run: python3 scripts/release-provenance-test.py""" + +from __future__ import annotations + +import datetime +import importlib.util +import json +import pathlib +import tempfile +import unittest +import unittest.mock + +HERE = pathlib.Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("release_provenance", HERE / "release-provenance.py") +provenance = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(provenance) + +A = "a" * 64 +B = "b" * 64 +COMMIT = "0123456789abcdef0123456789abcdef01234567" + +MANIFEST = f"""{A} sysml-linux-amd64.tar.gz +{B} opensysml-0.9.0-py3-none-any.whl +""" + +ENV = { + "CIRCLE_TAG": "v0.9.0", + "CIRCLE_SHA1": COMMIT, + "CIRCLE_BUILD_URL": "https://circleci.com/gh/Open-MBEE/OpenSysML/1234", + "CIRCLE_PROJECT_ID": "eeb0dddd-237f-4f02-9e51-8e24caef589d", + "CIRCLE_ORGANIZATION_ID": "1169df8b-0b59-400f-82d2-c9d8e98bdb62", + "CIRCLE_WORKFLOW_ID": "wf-1", + "CIRCLE_JOB": "build-release", + "CIRCLE_PROJECT_USERNAME": "Open-MBEE", + "CIRCLE_PROJECT_REPONAME": "OpenSysML", +} + +NOW = datetime.datetime(2026, 9, 26, 1, 2, 3, tzinfo=datetime.timezone.utc) + + +def build(**overrides): + env = dict(ENV) + env.update(overrides) + return provenance.Build.from_env(env, NOW) + + +class SubjectsTest(unittest.TestCase): + def test_every_manifest_line_is_a_subject_with_its_digest(self): + self.assertEqual( + provenance.subjects(MANIFEST), + [ + {"name": "sysml-linux-amd64.tar.gz", "digest": {"sha256": A}}, + {"name": "opensysml-0.9.0-py3-none-any.whl", "digest": {"sha256": B}}, + ], + ) + + def test_blank_lines_and_binary_mode_markers_are_accepted(self): + self.assertEqual( + [s["name"] for s in provenance.subjects(f"\n{A} *x.zip\n\n")], ["x.zip"] + ) + + def test_names_with_spaces_are_kept_whole(self): + self.assertEqual(provenance.subjects(f"{A} a b.tar.gz")[0]["name"], "a b.tar.gz") + + def test_an_empty_manifest_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "no artifacts"): + provenance.subjects("\n") + + def test_a_malformed_line_is_refused(self): + for line in (f"{A[:63]} short.tar.gz", f"{A.upper()} upper.tar.gz", "x.tar.gz", f"{A}"): + with self.subTest(line=line): + with self.assertRaisesRegex(provenance.ProvenanceError, "line 1"): + provenance.subjects(line) + + def test_a_name_listed_twice_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "twice"): + provenance.subjects(f"{A} x\n{B} x\n") + + +class BuildTest(unittest.TestCase): + def test_every_variable_is_required(self): + for name in provenance.REQUIRED_ENV: + with self.subTest(name=name): + with self.assertRaisesRegex(provenance.ProvenanceError, name): + build(**{name: ""}) + + def test_a_tag_that_is_not_a_release_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "release tag"): + build(CIRCLE_TAG="develop") + + def test_a_commit_that_is_not_a_full_hash_is_refused(self): + with self.assertRaisesRegex(provenance.ProvenanceError, "commit hash"): + build(CIRCLE_SHA1=COMMIT[:7]) + + +class StatementTest(unittest.TestCase): + def test_the_statement_describes_the_tag_commit_and_job(self): + got = provenance.statement(MANIFEST, build()) + self.assertEqual(got["_type"], "https://in-toto.io/Statement/v1") + self.assertEqual(got["predicateType"], "https://slsa.dev/provenance/v1") + self.assertEqual(len(got["subject"]), 2) + definition = got["predicate"]["buildDefinition"] + self.assertEqual(definition["buildType"], provenance.BUILD_TYPE) + self.assertEqual( + definition["externalParameters"], + { + "repository": "https://github.com/Open-MBEE/OpenSysML", + "ref": "refs/tags/v0.9.0", + "job": "build-release", + }, + ) + self.assertEqual( + definition["resolvedDependencies"], + [ + { + "uri": "git+https://github.com/Open-MBEE/OpenSysML@refs/tags/v0.9.0", + "digest": {"gitCommit": COMMIT}, + } + ], + ) + self.assertEqual( + definition["internalParameters"], + { + "organization": ENV["CIRCLE_ORGANIZATION_ID"], + "project": ENV["CIRCLE_PROJECT_ID"], + "workflow": "wf-1", + }, + ) + run = got["predicate"]["runDetails"] + self.assertEqual( + run["builder"]["id"], + "https://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d", + ) + self.assertEqual( + run["metadata"], + {"invocationId": ENV["CIRCLE_BUILD_URL"], "finishedOn": "2026-09-26T01:02:03Z"}, + ) + + def test_render_is_json_ending_in_a_newline(self): + text = provenance.render(MANIFEST, build()) + self.assertTrue(text.endswith("}\n")) + self.assertEqual(json.loads(text), provenance.statement(MANIFEST, build())) + + +class MainTest(unittest.TestCase): + def test_writes_the_statement_and_refuses_without_a_build(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = pathlib.Path(tmp, "SHA256SUMS.txt") + manifest.write_text(MANIFEST) + out = pathlib.Path(tmp, "provenance.intoto.json") + with unittest.mock.patch.dict("os.environ", ENV, clear=True): + self.assertEqual( + provenance.main(["--manifest", str(manifest), "--out", str(out)]), 0 + ) + self.assertEqual(len(json.loads(out.read_text())["subject"]), 2) + out.unlink() + with unittest.mock.patch.dict("os.environ", {}, clear=True): + self.assertEqual( + provenance.main(["--manifest", str(manifest), "--out", str(out)]), 1 + ) + self.assertFalse(out.exists()) + + def test_a_missing_manifest_is_an_error_not_a_traceback(self): + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp, "out.json") + with unittest.mock.patch.dict("os.environ", ENV, clear=True): + self.assertEqual( + provenance.main(["--manifest", f"{tmp}/missing", "--out", str(out)]), 1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release-provenance.py b/scripts/release-provenance.py new file mode 100755 index 0000000000..1895db2802 --- /dev/null +++ b/scripts/release-provenance.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Write the SLSA provenance statement for a release's checksum manifest. + +Usage: scripts/release-provenance.py --manifest dist/SHA256SUMS.txt --out dist/provenance.intoto.json + +The statement is an in-toto Statement v1 whose subjects are every artifact the +manifest lists, with the digest the manifest records, and whose predicate is +SLSA Provenance v1 describing the build that produced them: the repository and +tag built, the commit resolved, and the CircleCI job that ran. The release +pipeline signs the file with cosign keyless, so the statement itself carries no +signature and names no secret. + +The build is described from CircleCI's environment: CIRCLE_TAG, CIRCLE_SHA1, +CIRCLE_BUILD_URL, CIRCLE_PROJECT_ID, CIRCLE_ORGANIZATION_ID, CIRCLE_WORKFLOW_ID, +CIRCLE_JOB, CIRCLE_PROJECT_USERNAME and CIRCLE_PROJECT_REPONAME. Every one is +required: a statement missing any of them would describe a build that cannot be +told apart from another, so the script refuses rather than write a vaguer one. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import pathlib +import re +import sys + +STATEMENT_TYPE = "https://in-toto.io/Statement/v1" +PREDICATE_TYPE = "https://slsa.dev/provenance/v1" + +# Names this repository's build-release job; bump the version when the job's meaning changes. +BUILD_TYPE = "https://github.com/Open-MBEE/OpenSysML/.circleci/build-release/v1" + +_MANIFEST_LINE = re.compile(r"^([0-9a-f]{64}) [ *](\S.*)$") +_TAG = re.compile(r"^v[0-9]") +_SHA1 = re.compile(r"^[0-9a-f]{40}$") + +REQUIRED_ENV = ( + "CIRCLE_TAG", + "CIRCLE_SHA1", + "CIRCLE_BUILD_URL", + "CIRCLE_PROJECT_ID", + "CIRCLE_ORGANIZATION_ID", + "CIRCLE_WORKFLOW_ID", + "CIRCLE_JOB", + "CIRCLE_PROJECT_USERNAME", + "CIRCLE_PROJECT_REPONAME", +) + + +class ProvenanceError(Exception): + """The manifest or the build description cannot make a truthful statement.""" + + +class Build: + """What the provenance says about the build that produced the artifacts.""" + + def __init__(self, tag, commit, build_url, project_id, organization_id, workflow_id, + job, owner, repository, finished_on): + self.tag = tag + self.commit = commit + self.build_url = build_url + self.project_id = project_id + self.organization_id = organization_id + self.workflow_id = workflow_id + self.job = job + self.owner = owner + self.repository = repository + self.finished_on = finished_on + + @classmethod + def from_env(cls, env, now): + missing = [name for name in REQUIRED_ENV if not env.get(name)] + if missing: + raise ProvenanceError( + "cannot describe the build: " + ", ".join(missing) + " not set" + ) + if not _TAG.match(env["CIRCLE_TAG"]): + raise ProvenanceError( + f"CIRCLE_TAG {env['CIRCLE_TAG']!r} is not a release tag (v)" + ) + if not _SHA1.match(env["CIRCLE_SHA1"]): + raise ProvenanceError(f"CIRCLE_SHA1 {env['CIRCLE_SHA1']!r} is not a commit hash") + return cls( + tag=env["CIRCLE_TAG"], + commit=env["CIRCLE_SHA1"], + build_url=env["CIRCLE_BUILD_URL"], + project_id=env["CIRCLE_PROJECT_ID"], + organization_id=env["CIRCLE_ORGANIZATION_ID"], + workflow_id=env["CIRCLE_WORKFLOW_ID"], + job=env["CIRCLE_JOB"], + owner=env["CIRCLE_PROJECT_USERNAME"], + repository=env["CIRCLE_PROJECT_REPONAME"], + finished_on=now, + ) + + @property + def source(self): + return f"https://github.com/{self.owner}/{self.repository}" + + +def subjects(manifest_text): + """The artifacts a `sha256sum` manifest lists, as in-toto subjects. + + Every line must be `<64 hex digits> `; a name may appear once. An + empty manifest is refused, since provenance over nothing is not provenance. + """ + seen = set() + result = [] + for number, line in enumerate(manifest_text.splitlines(), start=1): + if not line.strip(): + continue + match = _MANIFEST_LINE.match(line) + if not match: + raise ProvenanceError(f"manifest line {number} is not a sha256sum line: {line!r}") + digest, name = match.group(1), match.group(2) + if name in seen: + raise ProvenanceError(f"manifest lists {name!r} twice") + seen.add(name) + result.append({"name": name, "digest": {"sha256": digest}}) + if not result: + raise ProvenanceError("manifest lists no artifacts") + return result + + +def statement(manifest_text, build): + """The in-toto statement over the manifest's artifacts for this build.""" + ref = f"refs/tags/{build.tag}" + return { + "_type": STATEMENT_TYPE, + "subject": subjects(manifest_text), + "predicateType": PREDICATE_TYPE, + "predicate": { + "buildDefinition": { + "buildType": BUILD_TYPE, + "externalParameters": { + "repository": build.source, + "ref": ref, + "job": build.job, + }, + "internalParameters": { + "organization": build.organization_id, + "project": build.project_id, + "workflow": build.workflow_id, + }, + "resolvedDependencies": [ + { + "uri": f"git+{build.source}@{ref}", + "digest": {"gitCommit": build.commit}, + } + ], + }, + "runDetails": { + "builder": { + "id": f"https://circleci.com/api/v2/projects/{build.project_id}", + }, + "metadata": { + "invocationId": build.build_url, + "finishedOn": build.finished_on.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + }, + }, + } + + +def render(manifest_text, build): + return json.dumps(statement(manifest_text, build), indent=2) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--manifest", required=True, type=pathlib.Path, + help="the sha256sum manifest over the release artifacts") + parser.add_argument("--out", required=True, type=pathlib.Path, + help="where to write the statement") + args = parser.parse_args(argv) + try: + build = Build.from_env(os.environ, datetime.datetime.now(datetime.timezone.utc)) + text = render(args.manifest.read_text(encoding="utf-8"), build) + except (ProvenanceError, OSError) as err: + print(f"release-provenance: {err}", file=sys.stderr) + return 1 + args.out.write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From c0534743e6bd6e253ee91fcc883508a8cd02b81b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:44:04 +0000 Subject: [PATCH 2/2] fix(release): leave finishedOn out of the provenance the build writes mid-job Co-Authored-By: jason.han --- scripts/release-provenance-test.py | 7 ++----- scripts/release-provenance.py | 10 +++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/scripts/release-provenance-test.py b/scripts/release-provenance-test.py index 540bd4e5c0..c25846d534 100755 --- a/scripts/release-provenance-test.py +++ b/scripts/release-provenance-test.py @@ -3,7 +3,6 @@ from __future__ import annotations -import datetime import importlib.util import json import pathlib @@ -37,13 +36,11 @@ "CIRCLE_PROJECT_REPONAME": "OpenSysML", } -NOW = datetime.datetime(2026, 9, 26, 1, 2, 3, tzinfo=datetime.timezone.utc) - def build(**overrides): env = dict(ENV) env.update(overrides) - return provenance.Build.from_env(env, NOW) + return provenance.Build.from_env(env) class SubjectsTest(unittest.TestCase): @@ -135,7 +132,7 @@ def test_the_statement_describes_the_tag_commit_and_job(self): ) self.assertEqual( run["metadata"], - {"invocationId": ENV["CIRCLE_BUILD_URL"], "finishedOn": "2026-09-26T01:02:03Z"}, + {"invocationId": ENV["CIRCLE_BUILD_URL"]}, ) def test_render_is_json_ending_in_a_newline(self): diff --git a/scripts/release-provenance.py b/scripts/release-provenance.py index 1895db2802..a8fa0d9977 100755 --- a/scripts/release-provenance.py +++ b/scripts/release-provenance.py @@ -20,7 +20,6 @@ from __future__ import annotations import argparse -import datetime import json import os import pathlib @@ -58,7 +57,7 @@ class Build: """What the provenance says about the build that produced the artifacts.""" def __init__(self, tag, commit, build_url, project_id, organization_id, workflow_id, - job, owner, repository, finished_on): + job, owner, repository): self.tag = tag self.commit = commit self.build_url = build_url @@ -68,10 +67,9 @@ def __init__(self, tag, commit, build_url, project_id, organization_id, workflow self.job = job self.owner = owner self.repository = repository - self.finished_on = finished_on @classmethod - def from_env(cls, env, now): + def from_env(cls, env): missing = [name for name in REQUIRED_ENV if not env.get(name)] if missing: raise ProvenanceError( @@ -93,7 +91,6 @@ def from_env(cls, env, now): job=env["CIRCLE_JOB"], owner=env["CIRCLE_PROJECT_USERNAME"], repository=env["CIRCLE_PROJECT_REPONAME"], - finished_on=now, ) @property @@ -158,7 +155,6 @@ def statement(manifest_text, build): }, "metadata": { "invocationId": build.build_url, - "finishedOn": build.finished_on.strftime("%Y-%m-%dT%H:%M:%SZ"), }, }, }, @@ -177,7 +173,7 @@ def main(argv=None): help="where to write the statement") args = parser.parse_args(argv) try: - build = Build.from_env(os.environ, datetime.datetime.now(datetime.timezone.utc)) + build = Build.from_env(os.environ) text = render(args.manifest.read_text(encoding="utf-8"), build) except (ProvenanceError, OSError) as err: print(f"release-provenance: {err}", file=sys.stderr)