diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 724c73df..a2ddb541 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: release_tag: - description: Existing immutable Castlabs prerelease tag + description: Existing immutable Castlabs dev-only prerelease tag (no +signer releases) required: true default: castlabs-v0.37.13.dev2 type: string @@ -26,6 +26,13 @@ jobs: contents: read id-token: write steps: + - name: Reject non-development release tags before checkout + shell: bash + env: + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + run: | + [[ "$INPUT_RELEASE_TAG" =~ ^castlabs-v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]] + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python @@ -85,7 +92,7 @@ jobs: cmp dist/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl \ download/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev2"); assert v.is_prerelease and str(v)=="0.37.13.dev2"' + python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev2"); assert v.is_devrelease and v.local is None and str(v)=="0.37.13.dev2"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 772a7e50..079a7d43 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -1,13 +1,13 @@ -name: Castlabs VSI prerelease +name: Castlabs VSI release on: push: tags: - - castlabs-v0.37.13.dev2 + - 'castlabs-v0.37.13\+signer.1.0.8' workflow_dispatch: inputs: source_sha: - description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev2; paired source for trusted_vsi_only) + description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13+signer.1.0.8; paired source for trusted_vsi_only) required: true type: string trusted_vsi_only: @@ -17,7 +17,7 @@ on: default: false concurrency: - group: castlabs-vsi-release-0.37.13.dev2 + group: castlabs-vsi-release-0.37.13+signer.1.0.8 cancel-in-progress: false permissions: @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -82,7 +82,7 @@ jobs: git -C c2pa-python fetch --no-tags origin \ refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi + if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ --python-root c2pa-python \ --rust-root c2pa-rs \ @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -133,7 +133,7 @@ jobs: git -C c2pa-python fetch --no-tags origin \ refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi + if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ --python-root c2pa-python --rust-root c2pa-rs \ --source-sha "${{ needs.prepare.outputs.source_sha }}" \ @@ -195,7 +195,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.13+signer.1.0.8-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -215,7 +215,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -303,7 +303,7 @@ jobs: } git -C c2pa-python fetch --no-tags origin ` refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional - $event = if ($env:GITHUB_REF.StartsWith("refs/tags/")) { "tag" } else { "manual" } + $event = if ($env:GITHUB_EVENT_NAME -eq "push") { "tag" } else { "manual" } python c2pa-python/scripts/castlabs_release.py validate-sources ` --python-root c2pa-python --rust-root c2pa-rs ` --source-sha "${{ needs.prepare.outputs.source_sha }}" ` @@ -351,7 +351,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.13+signer.1.0.8-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -371,7 +371,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -424,7 +424,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -466,7 +466,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -490,7 +490,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2' + if: github.event_name == 'push' && github.ref == 'refs/tags/castlabs-v0.37.13+signer.1.0.8' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -525,7 +525,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.13.dev2-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.13+signer.1.0.8-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -542,13 +542,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -562,7 +562,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.13.dev2 + TAG=castlabs-v0.37.13+signer.1.0.8 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -572,13 +572,13 @@ jobs: with: subject-path: assets/* - - name: Reconcile no-clobber draft prerelease + - name: Reconcile no-clobber draft final release shell: bash env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.13.dev2 + TAG=castlabs-v0.37.13+signer.1.0.8 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -604,7 +604,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev2")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.13+signer.1.0.8")' > "$1" return 0 } query_release_id() { @@ -634,10 +634,12 @@ jobs: CLEANUP_ARMED=0 fi local response="release-upload-${upload_index}.json" + local encoded_name + encoded_name=$(python3 -c 'import sys; from urllib.parse import quote; print(quote(sys.argv[1], safe=""))' "$asset_name") gh api --method POST \ -H "Content-Type: application/octet-stream" \ --input "assets/$asset_name" \ - "https://uploads.github.com/repos/castlabs/c2pa-python/releases/${RELEASE_ID}/assets?name=${asset_name}" \ + "https://uploads.github.com/repos/castlabs/c2pa-python/releases/${RELEASE_ID}/assets?name=${encoded_name}" \ > "$response" python3 c2pa-python/scripts/castlabs_release.py validate-upload-response \ --response "$response" --asset "assets/$asset_name" @@ -669,7 +671,7 @@ jobs: -f "target_commitish=$SOURCE_SHA" \ -f "name=$RELEASE_NAME" \ -f "body=$RELEASE_BODY" \ - -F draft=true -F prerelease=true \ + -F draft=true -F prerelease=false \ > release-created-response.json python3 c2pa-python/scripts/castlabs_release.py extract-release-id \ --release-json release-created-response.json \ diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 7983e116..a9a347d1 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -35,9 +35,9 @@ jobs: C2PA_REQUIRE_SIGN_LADDER: "1" C2PA_REQUIRE_FRAGMENTED_FILES: "1" C2PA_FRAGMENT_OFFSET_REQUIRED: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.13.dev2 + FUNCTIONAL_BUILD_VERSION: 0.37.13+signer.1.0.8 # The release smoke asserts the installed distribution version. - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 steps: - name: Require a full Python source SHA shell: bash @@ -52,9 +52,9 @@ jobs: with: repository: castlabs/c2pa-rs # Revision-3 trusted VSI plus offset-aware fragment verification: - # the same native commit the 0.37.13.dev2 release lock pins. This job + # the same native commit the 0.37.13+signer.1.0.8 release lock pins. This job # is qualification-only and never produces release artifacts. - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -105,7 +105,7 @@ jobs: shell: bash working-directory: python-source run: python -m pytest -q tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 - - name: Build separate development wheel and sdist (no publication) + - name: Build isolated aligned wheel and sdist (no publication) shell: bash working-directory: python-source run: python scripts/build_trusted_vsi_functional.py --library "$C2PA_LIBRARY_NAME" --out build/functional-qualification diff --git a/README.md b/README.md index cfbda439..dda46091 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,32 @@ export C2PA_FFI_FEATURES=file_io,unstable_live_video make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ``` -## Castlabs VSI prerelease process +## Castlabs VSI release process + +### Signer 1.0.8 preparation only + +The candidate package identity is `0.37.13+signer.1.0.8`, with intended tag +`castlabs-v0.37.13+signer.1.0.8`. This is preparation, not a qualified or +published candidate. The signer-aligned version is a PEP 440 local version for +a final, immutable **GitHub-only** release (`prerelease=false`), never PyPI. +The next development series is future work after publication; it is not selected +or applied in this candidate. + +The candidate selects the combined #27/#28/#29 native source +`6021100cc5060b06061ca15b634281cfeb97c718`, with `Cargo.lock` SHA-256 +`ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f` consistently +in the lock, schemas, helper, workflows, tests and current docs. Native bundle +and Python/native qualification remain publication/adoption gates. Integrate +and review the Python source on `feat/trusted-vsi-functional` and record CI +evidence against its final full SHA before any tag or publication. The local +preparation branch `release/signer-1.0.8` is not an authorized artifact-release source. ### Unreleased trusted-processor API qualification The [trusted-processor API](docs/usage.md#trusted-processor-vsi-unreleased-functional-api) -is separate from complete-buffer VSI and immutable dev5. It is first released in -`0.37.13.dev2`. Its probes are true only on a native library reporting trusted -contract revision 3 and capability mask 63 (the pinned release native does); +is separate from complete-buffer VSI and immutable dev5. The current prepared +release identity is `0.37.13+signer.1.0.8`. Its probes are true only on a native library reporting trusted +contract revision 3 and capability mask 63; otherwise every probe is false and session construction/operations fail before side effects. Expert signing accepts only exact `Sig_structure` bytes and returns the 64-byte signature; the earlier `TrustedVsiSignResult` was removed. Import @@ -118,17 +136,22 @@ library (`-k "not paired"`). Separately, both it and the dedicated workflow use Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather than skip. The paired Rust source is pinned to -`castlabs/c2pa-rs@afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as -the 0.37.13.dev2 release lock; the job logs resolved Python/Rust SHAs. The +`castlabs/c2pa-rs@6021100cc5060b06061ca15b634281cfeb97c718`, the merged #27/#28/#29 +source selected by the candidate release lock; the job logs resolved Python/Rust SHAs. The paired job is qualification only and is not release-artifact evidence. For the dedicated workflow, manually select the trusted-API workflow revision, set `trusted_vsi_only=true`, and supply its full Python `source_sha`. This path skips **all** release prepare/build/test/publish jobs. The reusable job builds -only an isolated native library and source-import metadata, never stages a -release native, uploads a wheel, or publishes anything. Ordinary release -dispatch and tag behavior, native pins, evidence, and release identity remain -unchanged. Do not package this ABI under the immutable dev5 artifact names. +an isolated native library, source-import metadata and non-publishing qualification +artifacts; it never stages a release native, uploads a wheel, or publishes anything. +Ordinary artifact builds retain the strict release-branch policy and tag-only +publication gate. Do not package this ABI under the immutable dev5 artifact names. +Paired wheel/sdist packaging copies source into a temporary directory and retains +the aligned version unchanged; installed-wheel qualification removes source and +library overrides. The existing qualification builder accepts this exact +signer-aligned source identity or a future development version. It still rejects +arbitrary final versions and historical identities, including the dev5 series. To test a locally built paired native without reinstalling the Python package: @@ -145,9 +168,9 @@ skips, or use `-k "not paired"` for baseline-only coverage. Neither is paired qualification. A fresh source checkout also needs distribution metadata for imports (`python setup.py egg_info`, without a native download or wheel build). -### Immutable Castlabs VSI prerelease process +### Immutable Castlabs VSI release process -The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev2` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. +The dedicated [`Castlabs VSI release`](.github/workflows/castlabs-vsi-release.yml) workflow is prepared to build version `0.37.13+signer.1.0.8` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. @@ -161,17 +184,17 @@ The immutable `castlabs-v0.37.13.dev1` tag (source `78771fa`, native `3328b2b4`) Release controls: -- Configure `castlabs-v0.37.13.dev2` as a protected tag and require approval for the `castlabs-vsi-release` environment. +- Before publication, configure `castlabs-v0.37.13+signer.1.0.8` as a protected tag and require approval for the `castlabs-vsi-release` environment. The workflow's single-quoted tag filter escapes the literal `+` as `\+`; wheel filenames and dist-info directories retain `+`, and upload query parameters percent-encode it as `%2B`. - A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/trusted-vsi-functional` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. - Source checkout URLs, full SHAs, package/native versions, and the pinned `Cargo.lock` SHA-256 are asserted before each build. - The Linux wheel is built and repaired in the digest-pinned `manylinux_2_28_x86_64` image. Every wheel is tested without capability skips on clean Python 3.10 through 3.13; Python 3.10 also runs the broader unit and fragmented-file suites. -- Draft release creation happens only after both platform builds and all wheel tests. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then checks prerelease state, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. -- [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) is a separate manual workflow protected by the `pypipublish` environment. It requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. +- Draft release creation happens only after both platform builds and all wheel tests, on the exact tag push, with `draft=true` and `prerelease=false`. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then requires `prerelease=false`, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. +- [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) remains a separate **dev-only** manual workflow protected by the `pypipublish` environment, pinned to historical `castlabs-v0.37.13.dev2`. It rejects tags outside `castlabs-vX.Y.Z.devN` before checkout; the current release helper also rejects non-development versions before creating any upload set. It cannot promote this aligned release. For its historical dev target it requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. - PyPI does not provide a multi-file transaction. Promotion therefore queries the version JSON API first. A fresh release uploads both platform wheels together; a retry accepts an existing wheel only when PyPI's SHA-256 exactly matches the local attested wheel, then uploads all missing wheels together. A mismatched or unexpected PyPI file fails. This policy intentionally publishes no source distribution for the two-platform prerelease and safely recovers from an unavoidable prior partial wheel upload. -Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev2 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. +Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually before any operator-authorized deletion of that exact draft and rerun the complete protected-tag workflow. Never delete a published release or use clobber to repair a mismatch. -`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev2`. +`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13+signer.1.0.8`. At the pinned c2pa-rs commit, VSI initialization signs through `Builder::from_shared_context`, so it inherits DynamicAssertions registered on the Context's claim signer. The common Python callback bridge enforces `reserve_size` as the exact serialized CBOR size for every signing path. The earlier `assertion.bmffHash.mismatch` came from returning only 5 bytes for a 64-byte reservation; it was not an inherent DynamicAssertion-plus-VSI incompatibility. The release smoke now returns valid exact-size CBOR, requires clean Reader validation for both standalone image and combined VSI init signing, and exercises combined media signing and recovery without an xfail. diff --git a/docs/release-notes.md b/docs/release-notes.md index f5e5d145..41dd9949 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,17 +1,47 @@ # Release notes -## Planned: signer-aligned release versions - -Decided 2026-10-16, effective from the next release built for a signer release -(signer `1.0.8` uses `0.37.13.dev2`). Such releases use a PEP 440 local version +## Version 0.37.13+signer.1.0.8 (Preparation Only) + +The updated signer `1.0.8` dependency uses the prepared package version +`0.37.13+signer.1.0.8` and intended tag `castlabs-v0.37.13+signer.1.0.8`. +This is not a qualified or published Python release. No tag or publication is +part of this preparation; paired Linux/Windows CI must qualify the exact sources. + +- Native inputs select `castlabs/c2pa-rs` + `6021100cc5060b06061ca15b634281cfeb97c718`, with `Cargo.lock` SHA-256 + `ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f`. + This combines #27 generic BMFF exclusions, ZIP 9.0.4 and WASI fixes with #28 + verified VSI init refreshes and #29's native CLI update timelines. The Python + runtime API is unchanged; the CLI is built separately by the signer from this + same native SHA. Exact-source focused native CI passed; complete native bundle + and Python/native artifact qualification remain release gates. +- Rust `1.96.0`, native `0.92.0-dev`, trusted contract revision 3, capability + mask 63, feature selection and hermetic/evidence/ownership controls are unchanged. + `c2pa-native-version.txt` remains the independent upstream prebuilt pin. +- Final GitHub release policy is `prerelease=false`, still created as a draft + for operator finalization. The literal `+` is escaped in the GitHub tag trigger, + retained in wheel/dist-info/bundle identities, and percent-encoded in asset + upload query parameters. PyPI is dev-only and rejects aligned releases. +- `feat/trusted-vsi-functional` remains the locked source branch; arbitrary branch + artifact builds are not authorized. Final Python-source review and CI + qualification are required before tagging. +- The qualification builder accepts the exact signer-aligned source identity or + a future development version, not arbitrary final versions. The next + development series is future work after publication, not a repository version + switch in this candidate. + +### Signer-aligned version policy + +Decided 2026-10-16 and applied here to the updated signer `1.0.8` dependency. +Such releases use a PEP 440 local version naming the signer version, `+signer.[.]`, for example `0.37.14+signer.1.0.9`, tagged `castlabs-v0.37.14+signer.1.0.9`. They are final, immutable GitHub releases (`prerelease=false`), not published to PyPI (local versions are rejected there). Development builds keep `.devN` versions, -and the development line moves to the next `.devN` series after each aligned -release. A signer retry under a new version gets a new `+signer` release. -Release-tooling changes (escaped tag trigger, prerelease flag, artifact names, -PyPI workflow limited to `.devN`) land with that release. +and moving the development line to the next `.devN` series is follow-up work +after each aligned release. A signer retry under a new version gets a new +`+signer` release. Historical release sections below describe their original +identities and inputs; they are not repinned for this candidate. ## Version 0.37.13.dev2 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 600df64f..51127cfa 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,19 +1,26 @@ # Trusted VSI Python Contract -Status: integrated, unreleased PR4 review changes requiring native trusted-VSI +Status: signer 1.0.8 candidate preparation requiring native trusted-VSI contract revision 3 (state version 3 and typed expert VSI payloads), following `c2pa-rs` -`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev2`, -prepared as the Castlabs VSI prerelease on native -`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`; immutable dev5 release inputs and -artifacts are unchanged. The paired source workflow pins the same native commit +`docs/trusted-vsi-native-contract.md`. The prepared Python identity is +`0.37.13+signer.1.0.8`, with intended tag `castlabs-v0.37.13+signer.1.0.8` for a +final, immutable GitHub-only release (`prerelease=false`), not PyPI. This is +preparation only, not Python qualification or publication. Native inputs select +`6021100cc5060b06061ca15b634281cfeb97c718`; immutable historical release inputs and +artifacts are unchanged. Its `Cargo.lock` SHA-256 is +`ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f`. +The combined native #27/#28/#29 source passed focused CI; complete native bundle +and Python/native artifact qualification remain release gates. The paired +source workflow pins this same native commit (previously `3328b2b440fd07d9f857cbefa3c6d565093425de` for the unpublished 0.37.13.dev1 and `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` before it) for revision-3 qualification. Select a reviewed local library with `C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract identity; Python now requires the explicit native contract-revision probe. -Local source and installed-wheel checks passed; hosted results must be recorded -against the final source SHAs before claiming Linux/Windows qualification. +Earlier local source and installed-wheel checks do not qualify this candidate; +hosted results must be recorded against the final source SHAs before claiming +Linux/Windows qualification. Prior results and machine-local provenance are archived in [review verification](archive/trusted-vsi-review-verification.md). @@ -240,18 +247,23 @@ CBOR, BMFF or validation results. ## Qualification Identity -`scripts/build_trusted_vsi_functional.py --library --out -` first requires all probes true against that library, then builds a -NON-PUBLISHING wheel and sdist in a temporary staging copy with -`FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity -`0.37.13.dev2`; rejects `0.37.8.*`, dev5, release versions and anything older -than the source identity, including `0.37.13.dev1`, `0.37.13.dev0` and the historical -`0.37.9.dev0`). `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native -library only. The release lock and tooling now match this source -(`castlabs-v0.37.13.dev2`); the immutable dev5 release remains historical. +The paired workflow requires all probes true against its exact native library, +then builds a NON-PUBLISHING wheel and sdist in a temporary source copy retaining +the source version `0.37.13+signer.1.0.8`. It stages only the paired native into +that copy, not the checkout or a release artifact directory. Its version variables +and the release lock/tooling match this identity. `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` +identifies the native library only; it does not attest a commit or qualification. + +`scripts/build_trusted_vsi_functional.py` accepts the checkout's exact +signer-aligned source identity or a future development version. It rejects +arbitrary final versions, dev5/`0.37.8.*`, and versions older than the checkout. +The paired workflow uses that existing builder's isolated staging and capability +probes without selecting a future development identity. The next development +series is future work after publication. Historical dev5 and dev2 +releases/tags/evidence are unchanged. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.13.dev2` installs the wheel into an isolated venv, strips +--version 0.37.13+signer.1.0.8` installs the wheel into an isolated venv, strips `PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, `test_fragmented_files.py`, `test_sign_ladder.py`, `test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, @@ -264,15 +276,15 @@ Paired tests require the full native library and FAIL under `C2PA_TRUSTED_VSI_ABI_REQUIRED=1` (all Linux/Windows qualification jobs); they skip only in ad-hoc local runs. `.github/workflows/trusted-vsi-paired.yml` builds the native with Rust 1.96.0 from castlabs/c2pa-rs -`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as the -`castlabs-v0.37.13.dev2` release lock (trusted contract revision 3, mask 63, +`6021100cc5060b06061ca15b634281cfeb97c718`, the same commit as the +`castlabs-v0.37.13+signer.1.0.8` preparation lock (trusted contract revision 3, mask 63, offset-aware fragment exports). It sets `C2PA_FRAGMENT_OFFSET_REQUIRED=1`, so a native library without the fragment-offset probe fails rather than skips. Older pins (`6b506352`, `203dc08d`, `5c186c07`) emitted state version 2 and lacked the revision probe; they cannot qualify this gate. No tests or capability gates are weakened. -## PR4 CI Gate +## CI And Publication Hold At head `12d265db92e8dcbf80b8255278e9a7fc5945f750`, Build run `36810322522` completed its version/format/tooling jobs but skipped the paired @@ -281,12 +293,12 @@ and no labels; the source gate already accepts `COLLABORATOR` and `MEMBER`. The retained run metadata does not establish the event-time association, so this is not evidence that the current MEMBER condition is wrong. No author gate is broadened here (in particular no speculative OWNER exception or fork secrets). -Do NOT apply the maintainer `safe to test` label or run paired CI expecting it to -pass until the final native revision is integrated and pinned in the separate -authorized integration step. The unchanged older native pin and the new -revision-3 gate are intentionally incompatible. Only after integration -should a maintainer review the head, apply the existing label to trigger the -`labeled` event, and confirm required jobs actually ran. This task does not post -labels, dispatch CI, or repin. A green workflow with skipped test jobs is not -qualification, and local targeted older-library compatibility checks do not lift -the integration hold. +The candidate remains on hold until CI qualifies the exact Python/native pair +and its installed wheels. Selecting native `6021100c` does not establish that +paired qualification. Artifact builds still require `feat/trusted-vsi-functional` +(manual runs must use its full tip SHA and ref) or the exact aligned tag; the +preparation branch `release/signer-1.0.8` is not an allowed artifact-release source. +The non-publishing paired path can test a reviewed preparation SHA before that +integration. A maintainer must review the head, retain the existing author/label +gate, and confirm required jobs actually ran. This preparation does not tag or +publish. A green workflow with skipped test jobs is not qualification. diff --git a/pyproject.toml b/pyproject.toml index 827f4385..ca9441b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.13.dev2" +version = "0.37.13+signer.1.0.8" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 18d07134..419b7d41 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.13.dev2"}, + "version": {"const": "0.37.13+signer.1.0.8"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,9 +49,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, + "commit": {"const": "6021100cc5060b06061ca15b634281cfeb97c718"}, "version": {"const": "0.92.0-dev"}, - "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} + "cargoLockSha256": {"const": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f"} } } } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index 5e8a982c..0e47f4e3 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,20 +2,20 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.13.dev2" + "version": "0.37.13+signer.1.0.8" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev2" + "releaseTag": "castlabs-v0.37.13+signer.1.0.8" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95", + "commit": "6021100cc5060b06061ca15b634281cfeb97c718", "version": "0.92.0-dev", - "cargoLockSha256": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + "cargoLockSha256": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" }, "rustToolchain": { "channel": "1.96.0", diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 6e0a84bd..7095c214 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev2"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13+signer.1.0.8"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/trusted-vsi-functional"}, - "releaseTag": {"const": "castlabs-v0.37.13.dev2"} + "releaseTag": {"const": "castlabs-v0.37.13+signer.1.0.8"} } }, "rustSource": { @@ -48,9 +48,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, + "commit": {"const": "6021100cc5060b06061ca15b634281cfeb97c718"}, "version": {"const": "0.92.0-dev"}, - "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} + "cargoLockSha256": {"const": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f"} } }, "installer": { diff --git a/scripts/build_trusted_vsi_functional.py b/scripts/build_trusted_vsi_functional.py index 7d59c798..19612338 100644 --- a/scripts/build_trusted_vsi_functional.py +++ b/scripts/build_trusted_vsi_functional.py @@ -1,6 +1,6 @@ """Build source-paired functional test artifacts, never release/publish dev5. -The development version is applied only in a temporary source staging tree. +The source-aligned identity or a future development version is applied only in a temporary source staging tree. The repository's version and immutable release inputs are left untouched. """ @@ -31,10 +31,14 @@ def source_version(root: Path = ROOT) -> str: def functional_version(value: str, root: Path = ROOT) -> str: version = Version(value) - if (not version.is_devrelease or version <= Version("0.37.8.dev5") - or version.release == (0, 37, 8)): + if version <= Version("0.37.8.dev5") or version.release == (0, 37, 8): raise ValueError("functional version must be a newer development series than 0.37.8") minimum = Version(source_version(root)) + # A final version is allowed only for this checkout's signer-aligned identity. + aligned_source = (version == minimum and not minimum.is_prerelease + and minimum.local is not None and minimum.local.startswith("signer.")) + if not version.is_devrelease and not aligned_source: + raise ValueError("functional version must be development or the exact signer-aligned source identity") if version < minimum: raise ValueError( f"functional version {version} must not be older than the source " diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index 35995525..02a4ffe3 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Hermetic release helpers for the Castlabs VSI prerelease.""" +"""Hermetic release helpers for the Castlabs VSI release.""" from __future__ import annotations @@ -26,26 +26,27 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.13.dev2" -RUST_COMMIT = "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" -CARGO_LOCK_SHA256 = "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" +RELEASE_VERSION = "0.37.13+signer.1.0.8" +RUST_COMMIT = "6021100cc5060b06061ca15b634281cfeb97c718" +CARGO_LOCK_SHA256 = "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.13.dev2" +RELEASE_TAG = f"castlabs-v{RELEASE_VERSION}" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" -RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" +RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable release" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" f"{RELEASE_IDENTITY_TEXT}. Trusted-processor VSI line (native contract " - "revision 3, capability mask 63) on castlabs/c2pa-rs afd6fffe (0.92.0-dev, " + "revision 3, capability mask 63) on castlabs/c2pa-rs 6021100c (0.92.0-dev, " "Rust 1.96.0), merged with the Conformance Program 0.2 / C2PA 2.4 line: " "crJSON 2.4 output with checked export, caller-controlled validation time, " "offset-aware single-file fMP4 fragment verification and the default EKU " - "trust-policy fix. Successor to dev1 run 38786913569; no dev1 release. See " + "trust-policy fix, generic BMFF hash exclusions, ZIP 9.0.4 remediation and " + "verified VSI init refreshes. Signer-aligned 1.0.8 release, GitHub only (not PyPI). See " "the attached schema-2 evidence and SHA-256 sidecars." ) @@ -222,12 +223,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.13.dev2") + fail(f"release lock package identity is not {RELEASE_VERSION}") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev2", + "releaseTag": RELEASE_TAG, }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] @@ -556,6 +557,7 @@ def wheel_details_bytes( native_members: list[tuple[str, bytes]] = [] metadata_members: list[bytes] = [] wheel_members: list[bytes] = [] + expected_dist_info = f"c2pa_python-{RELEASE_VERSION}.dist-info" with zipfile.ZipFile(io.BytesIO(payload)) as archive: names: set[str] = set() for info in archive.infolist(): @@ -574,8 +576,12 @@ def wheel_details_bytes( if member.parts[:2] == ("c2pa", "libs"): native_members.append((info.filename, contents)) if info.filename.endswith(".dist-info/METADATA"): + if member.parts != (expected_dist_info, "METADATA"): + fail(f"unexpected wheel dist-info directory: {info.filename}") metadata_members.append(contents) if info.filename.endswith(".dist-info/WHEEL"): + if member.parts != (expected_dist_info, "WHEEL"): + fail(f"unexpected wheel dist-info directory: {info.filename}") wheel_members.append(contents) if len(native_members) != 1: fail(f"wheel must contain exactly one native library: {name}") @@ -1163,8 +1169,8 @@ def validate_release_identity( fail("GitHub release response has the wrong release ID") if release_data.get("draft") is not True: fail("GitHub release is published; refusing to modify it") - if release_data.get("prerelease") is not True: - fail("GitHub draft is not marked as a prerelease") + if release_data.get("prerelease") is not False: + fail("GitHub draft must have prerelease=false for the signer-aligned release") if release_data.get("name") != RELEASE_NAME: fail("GitHub draft has the wrong release name") normalized_body = normalize_release_body(release_data.get("body")) @@ -1469,6 +1475,8 @@ def command_verify_draft_release(args: argparse.Namespace) -> None: def pypi_missing_wheels( wheels_dir: Path, http_status: int, response: dict[str, Any] | None ) -> tuple[dict[str, Path], list[str]]: + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+", RELEASE_VERSION): + fail("PyPI promotion accepts dev-only X.Y.Z.devN versions, not aligned releases") wheels = exact_files(wheels_dir, expected_wheel_names(), "PyPI wheel inputs") if http_status == 404: return wheels, sorted(wheels) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 654330ab..6c8c0f2f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,7 +11,7 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.13.dev2 +# Version: 0.37.13+signer.1.0.8 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 8dd940a6..0c10862f 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -46,7 +46,9 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev2") +EXPECTED_VERSION = os.environ.get( + "CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13+signer.1.0.8" +) REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 09f7cadf..180022ad 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -9,6 +9,7 @@ import re import shutil import subprocess +import sys import tarfile import tempfile import textwrap @@ -16,6 +17,7 @@ import zipfile from pathlib import Path from types import SimpleNamespace +from urllib.parse import parse_qs import pytest @@ -48,12 +50,14 @@ def _posix_bash() -> str: raise AssertionError(f"Git for Windows bash not found near {git}") -def test_prerelease_version_is_consistent(): +def test_signer_aligned_release_version_is_consistent(): # The release identity is consistent across the tooling, lock and source, - # so validate-sources accepts this checkout under the 0.37.13.dev2 lock. - assert release.RELEASE_VERSION == "0.37.13.dev2" - assert release.RELEASE_TAG == "castlabs-v0.37.13.dev2" - assert release.load_lock()["package"]["version"] == "0.37.13.dev2" + # while validate-sources retains the strict release-branch/tag policy. + assert release.RELEASE_VERSION == "0.37.13+signer.1.0.8" + assert release.RELEASE_TAG == "castlabs-v0.37.13+signer.1.0.8" + assert release.load_lock()["package"]["version"] == release.RELEASE_VERSION + assert "immutable release" in release.RELEASE_IDENTITY_TEXT + assert "not PyPI" in release.RELEASE_BODY source = release.project_version(ROOT) assert source == release.RELEASE_VERSION first_line = ( @@ -68,9 +72,9 @@ def test_release_lock_and_schemas_are_valid_json(): assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 assert lock["rustToolchain"]["channel"] == "1.96.0" - assert release.RUST_COMMIT == "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" + assert release.RUST_COMMIT == "6021100cc5060b06061ca15b634281cfeb97c718" assert release.CARGO_LOCK_SHA256 == ( - "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" ) assert lock["rustSource"]["version"] == "0.92.0-dev" assert lock["pythonSource"]["releaseBranch"] == "feat/trusted-vsi-functional" @@ -127,10 +131,10 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_release(): assert 'C2PA_REQUIRE_SIGN_LADDER: "1"' in paired assert 'C2PA_REQUIRE_FRAGMENTED_FILES: "1"' in paired assert "tests/ladder_native.py --lane candidate" in paired - assert "scripts/build_trusted_vsi_functional.py" in paired + assert 'python scripts/build_trusted_vsi_functional.py --library "$C2PA_LIBRARY_NAME" --out build/functional-qualification' in paired assert "scripts/qualify_trusted_vsi_functional.py" in paired for forbidden in ("download_artifacts.py", "castlabs_release.py", - "upload-artifact@", "bdist_wheel", "contents: write", + "upload-artifact@", "contents: write", "id-token: write", "continue-on-error", "gh release"): assert forbidden not in paired @@ -195,7 +199,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.13+signer.1.0.8-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -246,8 +250,13 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2'" in release_workflow - assert "-F draft=true -F prerelease=true" in release_workflow + escaped_tag = release.RELEASE_TAG.replace("+", r"\+") + assert f" - '{escaped_tag}'" in release_workflow + assert f"if: github.event_name == 'push' && github.ref == 'refs/tags/{release.RELEASE_TAG}'" in release_workflow + assert release_workflow.count('if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi') == 2 + assert '$event = if ($env:GITHUB_EVENT_NAME -eq "push") { "tag" } else { "manual" }' in release_workflow + assert "-F draft=true -F prerelease=false" in release_workflow + assert "prerelease=true" not in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow assert "inspect-draft-release" in release_workflow @@ -264,7 +273,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "target_commitish=$SOURCE_SHA" in release_workflow assert ( "https://uploads.github.com/repos/castlabs/c2pa-python/releases/" - "${RELEASE_ID}/assets?name=${asset_name}" in release_workflow + "${RELEASE_ID}/assets?name=${encoded_name}" in release_workflow ) assert "validate-upload-response" in release_workflow assert release_workflow.count("return 0") >= 5 @@ -309,6 +318,11 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_workflow.index("inspect-draft-release") ) assert "environment: pypipublish" in pypi_workflow + guard = pypi_workflow.index("Reject non-development release tags before checkout") + assert guard < pypi_workflow.index("uses: actions/checkout@") + assert r"^castlabs-v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$" in pypi_workflow + assert "v.is_devrelease and v.local is None" in pypi_workflow + assert release.RELEASE_TAG not in pypi_workflow assert ".draft == false and .prerelease == true" in pypi_workflow assert ( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" @@ -381,7 +395,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2") == 2 + assert release_workflow.count(f"CASTLABS_RELEASE_EXPECTED_VERSION: {release.RELEASE_VERSION}") == 2 assert release_workflow.count("C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev") == 2 # pytest is not a wheel runtime dependency: both clean installed-wheel # environments install it after the wheel, before running pytest. @@ -464,12 +478,74 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.13.dev2" in release_notes + assert "## Version 0.37.13+signer.1.0.8 (Preparation Only)" in release_notes assert "### 0.37.13.dev1 (not released)" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes +def test_upload_query_preserves_local_version_identifiers(): + workflow = (ROOT / ".github/workflows/castlabs-vsi-release.yml").read_text( + encoding="utf-8" + ) + assignment = next( + line.strip() for line in workflow.splitlines() + if line.strip().startswith("encoded_name=$(python3") + ) + for name in release.expected_release_asset_names(): + result = subprocess.run( + [_posix_bash(), "-c", 'python3() { "$TEST_PYTHON" "$@"; }\n' + + assignment + '\nprintf "%s" "$encoded_name"'], + env=dict(os.environ, asset_name=name, TEST_PYTHON=Path(sys.executable).as_posix()), + text=True, capture_output=True, + check=True, + ).stdout + assert parse_qs(f"name={result}") == {"name": [name]} + if "+" in name: + assert "%2B" in result + assert "+" not in result + + +@pytest.mark.parametrize("event, ref", [ + ("manual", "refs/heads/release/signer-1.0.8"), + ("manual", "refs/heads/main"), + ("manual", "refs/tags/castlabs-v0.37.13+signer.1.0.8"), + ("tag", "refs/tags/castlabs-v0.37.13.dev2"), +]) +def test_aligned_release_does_not_relax_source_ref_policy(monkeypatch, tmp_path, event, ref): + monkeypatch.setattr(release, "project_version", lambda root: release.RELEASE_VERSION) + def fake_git(root, *args): + if args == ("remote", "get-url", "origin"): + return "https://github.com/castlabs/c2pa-python.git" + assert args == ("rev-parse", "HEAD") + return TEST_SOURCE_SHA + monkeypatch.setattr(release, "run_git", fake_git) + with pytest.raises(SystemExit, match="release event ref must be"): + release.command_validate_sources(SimpleNamespace( + python_root=str(tmp_path), rust_root=str(tmp_path), + source_sha=TEST_SOURCE_SHA, repository="castlabs/c2pa-python", + event=event, ref=ref, + )) + + +def test_manual_release_still_requires_locked_branch_tip(monkeypatch, tmp_path): + monkeypatch.setattr(release, "project_version", lambda root: release.RELEASE_VERSION) + def fake_git(root, *args): + if args == ("remote", "get-url", "origin"): + return "https://github.com/castlabs/c2pa-python.git" + if args == ("rev-parse", "HEAD"): + return TEST_SOURCE_SHA + assert args == ("rev-parse", "refs/remotes/origin/feat/trusted-vsi-functional") + return "b" * 40 + monkeypatch.setattr(release, "run_git", fake_git) + with pytest.raises(SystemExit, match="locked release branch tip"): + release.command_validate_sources(SimpleNamespace( + python_root=str(tmp_path), rust_root=str(tmp_path), + source_sha=TEST_SOURCE_SHA, repository="castlabs/c2pa-python", + event="manual", ref="refs/heads/feat/trusted-vsi-functional", + )) + + def test_cargo_execution_and_evidence_share_the_locked_command(monkeypatch, tmp_path): calls = [] @@ -660,7 +736,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl" + wheel = root / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -671,7 +747,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: @@ -724,11 +800,12 @@ def _write_wheel( bom: bool = False, duplicate_name: bool = False, duplicate_version: bool = False, + dist_info: str | None = None, ) -> None: lock = release.load_lock() platform_tag = lock["targets"][target]["wheelPlatformTag"] native_name = lock["targets"][target]["library"] - dist_info = f"c2pa_python-{release.RELEASE_VERSION}.dist-info" + dist_info = dist_info or f"c2pa_python-{release.RELEASE_VERSION}.dist-info" metadata_lines = [ "Metadata-Version: 2.1", "Name: c2pa-python", @@ -813,6 +890,27 @@ def test_wheel_metadata_parser_handles_crlf_bom_and_rejects_duplicates(tmp_path) release.wheel_details(wheel, "x86_64-pc-windows-msvc", "Python 3.10.0") +@pytest.mark.parametrize("target, platform", [ + ("x86_64-unknown-linux-gnu", "manylinux_2_28_x86_64"), + ("x86_64-pc-windows-msvc", "win_amd64"), +]) +def test_wheel_local_version_filename_metadata_and_dist_info(tmp_path, target, platform): + wheel = tmp_path / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-{platform}.whl" + assert wheel.name in release.expected_wheel_names() + _write_wheel(wheel, target, b"native") + assert release.wheel_details(wheel, target, "3.10")["file"] == wheel.name + with zipfile.ZipFile(wheel) as archive: + assert f"c2pa_python-{release.RELEASE_VERSION}.dist-info/METADATA" in archive.namelist() + for version in ("0.37.13", "0.37.13.dev2", "0.37.13_signer.1.0.8"): + _write_wheel(wheel, target, b"native", dist_info=f"c2pa_python-{version}.dist-info") + with pytest.raises(SystemExit, match="unexpected wheel dist-info directory"): + release.wheel_details(wheel, target, "3.10") + with pytest.raises(SystemExit, match="unexpected wheel filename"): + release.wheel_details_bytes( + wheel.name.replace("+", "_"), wheel.read_bytes(), target, "3.10" + ) + + def test_verified_wheel_native_is_exact_qualified_artifact(tmp_path): native = b"qualified-native-bytes" wheel = tmp_path / ( @@ -982,7 +1080,7 @@ def _release_json(paths: dict[str, Path], names: set[str], *, draft=True): "id": 77, "tag_name": release.RELEASE_TAG, "draft": draft, - "prerelease": True, + "prerelease": False, "name": release.RELEASE_NAME, "body": release.RELEASE_BODY, "target_commitish": TEST_SOURCE_SHA, @@ -1115,7 +1213,7 @@ def test_release_response_validators_cover_owned_empty_and_complete_states(tmp_p ("body", "wrong-body"), ("target_commitish", "b" * 40), ("draft", False), - ("prerelease", False), + ("prerelease", True), ): invalid = copy.deepcopy(create_response) invalid[field] = bad_value @@ -1267,7 +1365,43 @@ def _pypi_response(wheels: dict[str, Path], names: set[str]) -> dict: } -def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path): +@pytest.fixture +def development_release(monkeypatch): + # Exercise the retained retry policy without allowing the aligned release + # to reach PyPI. Production inputs still require the exact committed lock. + lock = copy.deepcopy(release.load_lock()) + version = "0.37.13.dev2" + tag = f"castlabs-v{version}" + lock["package"]["version"] = version + lock["pythonSource"]["releaseTag"] = tag + monkeypatch.setattr(release, "RELEASE_VERSION", version) + monkeypatch.setattr(release, "RELEASE_TAG", tag) + monkeypatch.setattr(release, "load_lock", lambda: lock) + + +@pytest.mark.parametrize("version", [ + "0.37.13+signer.1.0.8", "0.37.13.dev2+signer.1.0.8", + "0.37.13", "0.37.13rc1", "0.37.13.dev2junk", +]) +def test_pypi_rejects_non_development_versions_before_reading_wheels( + monkeypatch, tmp_path, version +): + monkeypatch.setattr(release, "RELEASE_VERSION", version) + with pytest.raises(SystemExit, match="dev-only"): + release.pypi_missing_wheels(tmp_path / "absent", 404, None) + with pytest.raises(SystemExit, match="dev-only"): + release.command_pypi_plan(SimpleNamespace( + wheels_dir=str(tmp_path / "absent"), http_status="404", + response=str(tmp_path / "absent.json"), + destination=str(tmp_path / "publish"), output=str(tmp_path / "plan.json"), + github_output=str(tmp_path / "github-output"), + )) + assert not (tmp_path / "publish").exists() + assert not (tmp_path / "plan.json").exists() + assert not (tmp_path / "github-output").exists() + + +def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path, development_release): wheels_dir = tmp_path / "wheels" wheels = _write_policy_wheels(wheels_dir) names = set(wheels) @@ -1299,7 +1433,7 @@ def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path): release.pypi_missing_wheels(wheels_dir, 200, unexpected) -def test_pypi_command_stages_all_missing_wheels_together(tmp_path): +def test_pypi_command_stages_all_missing_wheels_together(tmp_path, development_release): wheels_dir = tmp_path / "wheels" wheels = _write_policy_wheels(wheels_dir) existing = {sorted(wheels)[0]} diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index 587a4161..32c8e8c0 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -15,8 +15,10 @@ SPEC.loader.exec_module(functional_build) -@pytest.mark.parametrize("version", ["0.37.13.dev2", "0.37.13.dev3", "0.38.0.dev1", "1.0.dev0+local"]) -def test_new_functional_development_version(version): +@pytest.mark.parametrize("version", [ + "0.37.13+signer.1.0.8", "0.37.14.dev0", "0.37.14.dev1", "0.38.0.dev1", "1.0.dev0+local", +]) +def test_functional_source_or_future_development_version(version): assert functional_build.functional_version(version) == version @@ -27,13 +29,16 @@ def test_new_functional_development_version(version): "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", "0.37.13.dev1", # Conformance-line prereleases merged into this line are older too. "0.37.12.dev4", + "0.37.13.dev2", "0.37.13.dev3", "0.37.13", "0.37.13+signer.1.0.9", + "0.37.13+signer.1.0.8.1", "0.37.14+signer.1.0.8", ]) def test_functional_build_rejects_release_or_dev5_identity(version): with pytest.raises(ValueError): functional_build.functional_version(version) -def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): +@pytest.mark.parametrize("selected_version", ["0.37.13+signer.1.0.8", "0.37.14.dev0"]) +def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path, selected_version): import subprocess import sys import tarfile @@ -41,9 +46,9 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} stage = tmp_path / "stage" stage.mkdir() - functional_build.stage_source(stage, "0.37.13.dev3") - assert 'version = "0.37.13.dev3"' in (stage / "pyproject.toml").read_text() - assert "# Version: 0.37.13.dev3" in (stage / "src/c2pa/c2pa.py").read_text() + functional_build.stage_source(stage, selected_version) + assert f'version = "{selected_version}"' in (stage / "pyproject.toml").read_text() + assert f"# Version: {selected_version}" in (stage / "src/c2pa/c2pa.py").read_text() assert not (stage / "src/c2pa/libs").exists() subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], cwd=stage, check=True, capture_output=True) @@ -52,12 +57,12 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): (sdist,) = (tmp_path / "dist").iterdir() name, version = parse_sdist_filename(sdist.name) assert canonicalize_name(name) == "c2pa-python" - assert version == Version("0.37.13.dev3") + assert version == Version(selected_version) root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() info = archive.extractfile(f"{root}/PKG-INFO").read().decode() - assert "Version: 0.37.13.dev3" in info.splitlines() + assert f"Version: {selected_version}" in info.splitlines() for member in ("scripts/build_trusted_vsi_functional.py", "scripts/qualify_trusted_vsi_functional.py", "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): @@ -66,13 +71,20 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): after = {name: (ROOT / name).read_bytes() for name in before} assert after == before # The checkout keeps its own source identity; staging never rewrites it. - assert 'version = "0.37.13.dev2"' in before["pyproject.toml"].decode() + assert 'version = "0.37.13+signer.1.0.8"' in before["pyproject.toml"].decode() def test_default_build_version_is_the_source_identity(monkeypatch): monkeypatch.delenv("FUNCTIONAL_BUILD_VERSION", raising=False) - assert functional_build.source_version() == "0.37.13.dev2" - assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev2" + assert functional_build.source_version() == "0.37.13+signer.1.0.8" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13+signer.1.0.8" + + +@pytest.mark.parametrize("version", ["0.37.13", "0.37.13+vendor.1"]) +def test_final_source_without_signer_identity_is_not_qualified(tmp_path, version): + (tmp_path / "pyproject.toml").write_text(f'[project]\nversion = "{version}"\n') + with pytest.raises(ValueError): + functional_build.functional_version(version, tmp_path) def test_probe_script_requires_every_functional_capability():