diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ca48982..b4c5633a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,6 +102,11 @@ jobs: echo "manifest=${manifest}" } >> "${GITHUB_OUTPUT}" + - name: Verify lock-bearing release version + run: | + release/scripts/registry-release verify-registryctl-image-lock-release-version \ + --version "${{ steps.release.outputs.version }}" + - name: Validate release manifest run: release/scripts/registry-release validate "${{ steps.release.outputs.manifest }}" @@ -191,6 +196,12 @@ jobs: (cd dist/bin && sha256sum -- * > SHA256SUMS) (cd dist/image-bin && sha256sum -- * > SHA256SUMS) + - name: Verify built registryctl binary version + run: | + release/scripts/registry-release verify-registryctl-binary-version \ + "dist/bin/registryctl-${{ needs.verify.outputs.tag }}-linux-amd64" \ + --version "${{ needs.verify.outputs.version }}" + - name: Upload binary artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -445,13 +456,43 @@ jobs: name: registry-stack-registryctl-linux-arm64 path: dist/bin - - name: Refresh binary checksums + - name: Verify registryctl binary version + run: | + chmod 0755 "dist/bin/registryctl-${{ needs.verify.outputs.tag }}-linux-amd64" + release/scripts/registry-release verify-registryctl-binary-version \ + "dist/bin/registryctl-${{ needs.verify.outputs.tag }}-linux-amd64" \ + --version "${{ needs.verify.outputs.version }}" + + - name: Download image evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: registry-stack-image-evidence + path: dist/image-evidence + + - name: Render registryctl release image lock + shell: bash + run: | + set -euo pipefail + mapfile -d '' -t relay_digests < <(find dist/image-evidence -type f -name registry-relay.digest -print0) + mapfile -d '' -t notary_digests < <(find dist/image-evidence -type f -name registry-notary.digest -print0) + if [[ "${#relay_digests[@]}" -ne 1 || "${#notary_digests[@]}" -ne 1 ]]; then + echo "Expected exactly one Relay and one Notary digest evidence file" >&2 + exit 1 + fi + release/scripts/registry-release render-registryctl-image-lock \ + "${{ needs.verify.outputs.manifest }}" \ + --relay-digest "${relay_digests[0]}" \ + --notary-digest "${notary_digests[0]}" \ + --tag-target "${{ needs.verify.outputs.tag_target }}" \ + --output "dist/bin/registryctl-${{ needs.verify.outputs.tag }}-image-lock.json" + + - name: Refresh release file checksums shell: bash run: | set -euo pipefail # The hermetic builder's SHA256SUMS covers only the linux/amd64 binaries. - # Regenerate over the full dist/bin set so the macOS/arm64 binaries are - # checksummed for the release capsule, SHA256SUMS asset, and install.sh. + # Regenerate over the full dist/bin set so cross-platform binaries and + # the registryctl image lock are covered by the capsule and installer. (cd dist/bin && rm -f SHA256SUMS && sha256sum -- * > SHA256SUMS) - name: Install Syft @@ -469,28 +510,22 @@ jobs: tar -xzf /tmp/registry-stack-tools/syft.tar.gz -C "${HOME}/.local/bin" syft echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" - - name: Generate binary SBOMs + - name: Generate release file SBOMs shell: bash run: | set -euo pipefail mkdir -p dist/binary-sbom - while IFS= read -r -d '' binary; do - asset="$(basename "${binary}")" + while IFS= read -r -d '' release_file; do + asset="$(basename "${release_file}")" sbom="dist/binary-sbom/${asset}.spdx.json" - syft "file:${binary}" -o "spdx-json=${sbom}" - digest="$(sha256sum "${binary}" | awk '{print $1}')" + syft "file:${release_file}" -o "spdx-json=${sbom}" + digest="$(sha256sum "${release_file}" | awk '{print $1}')" release/scripts/registry-release bind-spdx-file-subject \ "${sbom}" \ --file-name "${asset}" \ --sha256 "${digest}" done < <(find dist/bin -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z) - - name: Download image evidence - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - name: registry-stack-image-evidence - path: dist/image-evidence - - name: Render release capsule shell: bash run: | @@ -509,7 +544,8 @@ jobs: --workflow-run-id "${{ github.run_id }}" \ --default-branch-protection unverified \ --repo . \ - --default-branch origin/main + --default-branch origin/main \ + --require-registryctl-image-lock - name: Generate provenance subjects id: provenance-subjects diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index b975eef8..619f6535 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- Release distributions now include a strict, versioned registryctl image lock + that binds the release source and exact Relay and Notary image digests. Project + generation fails before mutation when the matching lock is missing or invalid. + +### Changed + +- The installer checksum-verifies and installs the registryctl binary and image + lock together. Existing generated projects still start from their stored image + pins without consulting the lock. + ### Fixed - Generated local Relay and Notary configs now declare `deployment.profile: local`, @@ -13,6 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Aggregated doctor JSON now preserves each product report's optional `audit_shipping` section and validates it against `registryctl.validation.report.v1`. +- Generated project image pins now come from tag-built release evidence instead + of digests compiled into registryctl, closing the stale-pin failure tracked in + GH#278 without using mutable image tags or live registry lookup. ## [0.8.4] - 2026-07-04 diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index 74c55838..18fddb44 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -8,8 +8,10 @@ Install a pinned release without cloning this repo: curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.8.4/crates/registryctl/install.sh | bash ``` -The quick installer verifies the downloaded binary against `SHA256SUMS` only. -It does not verify cosign signatures or SLSA provenance; use +The quick installer verifies downloaded release assets against `SHA256SUMS` +only. It installs the binary for releases before `v0.9.0`; beginning with +`v0.9.0`, it installs the binary and matching image lock beside each other. It +does not verify cosign signatures or SLSA provenance; use [`release/VERIFY.md`](../../release/VERIFY.md) for release authenticity checks. Then create and start your first secured spreadsheet API: @@ -52,11 +54,12 @@ The installer defaults to `v0.8.4`. To install a different pinned release, set `REGISTRYCTL_VERSION`: ```sh -curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.8.4/crates/registryctl/install.sh | REGISTRYCTL_VERSION=vX.Y.Z bash +curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/vX.Y.Z/crates/registryctl/install.sh | REGISTRYCTL_VERSION=vX.Y.Z bash ``` -The installer still fetches the script from `v0.8.4`; `REGISTRYCTL_VERSION` -selects the release asset to install. +Fetch the installer from the same pinned tag selected by +`REGISTRYCTL_VERSION`. An older installer does not know the asset contract of a +newer release. Prebuilt binaries are published for the `v0.8.4` stack release on Linux x86_64, Linux arm64, and macOS arm64. On other platforms, install from source with @@ -64,6 +67,26 @@ Linux arm64, and macOS arm64. On other platforms, install from source with Intel macOS has no prebuilt binary for `v0.8.4`, so the installer stops after printing that Cargo command. It does not run the source build automatically. +## Release image lock (`v0.9.0` and later) + +`registryctl init` and `registryctl add` read +`registryctl-vX.Y.Z-image-lock.json` beside the running binary before writing +project files. The strict lock binds the CLI release, source commit, tag target, +`linux/amd64` platform, and exact Relay and Notary image digests. Registryctl +does not discover images from mutable tags or a live registry. + +For `v0.9.0` and later, if you move or build the binary separately, place the +checksum-verified image lock from the same release beside it. An operator or +source test can set `REGISTRYCTL_IMAGE_LOCK` to an explicit verified lock path. +Registryctl never searches the current working directory for a lock, and +rejects a missing, mismatched, oversized, symlinked, or structurally invalid +file. + +Existing projects do not need the lock for `start`, `stop`, `status`, or other +runtime commands. They keep using the immutable image references already stored +in `registryctl.yaml` and `compose.yaml`. A later `init` or `add` is a generation +operation and requires the lock for that registryctl version. + ## Update checks `registryctl` checks GitHub releases at most once per day for normal @@ -133,16 +156,15 @@ shared crates have fresh release tags. ## End-to-end smoke -The generated project uses the digest-pinned Registry Relay image recorded in the registryctl -templates, not a floating image tag. With Docker Compose available, run: +The generated project uses the digest-pinned Registry Relay image recorded in +the matching registryctl release image lock, not a floating image tag. The +source tutorial gate builds registryctl and the product images from the same +checkout, places a strict test lock beside the binary, rebinds the generated +project to those local images, and executes both reader tutorials. With Docker +and the docs dependencies available, run: ```sh -tmpdir="$(mktemp -d)" -cargo run -- init relay "$tmpdir/my-first-api" --sample benefits -cd "$tmpdir/my-first-api" -registryctl doctor --profile local --format json -registryctl start -registryctl status -registryctl smoke -registryctl stop +cd docs/site +npm ci +npm run check:tutorial:registryctl ``` diff --git a/crates/registryctl/install.sh b/crates/registryctl/install.sh index 659ef187..a24108d9 100755 --- a/crates/registryctl/install.sh +++ b/crates/registryctl/install.sh @@ -11,9 +11,11 @@ usage() { cat < 0 || version_minor >= 9)); then + requires_image_lock=1 +fi + need curl need uname @@ -67,16 +78,22 @@ esac source_hint() { echo "Install registryctl from source instead:" >&2 echo " cargo install --git https://github.com/${repo} --tag ${version} registryctl --locked" >&2 + if ((requires_image_lock)); then + echo "Project generation also requires the checksum-verified ${version} image lock beside the installed binary." >&2 + fi } asset="registryctl-${version}-${os_label}-${arch_label}" +lock_asset="registryctl-${version}-image-lock.json" base_url="https://github.com/${repo}/releases/download/${version}" tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t registryctl)" cleanup() { rm -rf "$tmpdir" } -trap cleanup EXIT INT TERM +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM download() { local src="$1" @@ -92,36 +109,61 @@ if ! download "$base_url/$asset" "$tmpdir/$asset"; then exit 1 fi +if ((requires_image_lock)); then + if ! download "$base_url/$lock_asset" "$tmpdir/$lock_asset"; then + printf 'Could not download the matching registryctl image lock %s.\n' "$lock_asset" >&2 + printf 'The installer will not install a v0.9.0+ binary without its release image lock.\n' >&2 + exit 1 + fi +fi + if ! download "$base_url/SHA256SUMS" "$tmpdir/SHA256SUMS"; then echo "Could not download SHA256SUMS for checksum verification." >&2 exit 1 fi -expected_hash="$(awk -v asset="$asset" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" -if [ -z "$expected_hash" ]; then - echo "SHA256SUMS has no entry for $asset" >&2 - exit 1 -fi +sha256_file() { + local path="$1" + local result + if command -v shasum >/dev/null 2>&1; then + result="$(shasum -a 256 "$path")" + elif command -v sha256sum >/dev/null 2>&1; then + result="$(sha256sum "$path")" + else + echo "registryctl installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 + exit 1 + fi + printf '%s\n' "${result%% *}" +} -if command -v shasum >/dev/null 2>&1; then - actual_hash="$(shasum -a 256 "$tmpdir/$asset")" -elif command -v sha256sum >/dev/null 2>&1; then - actual_hash="$(sha256sum "$tmpdir/$asset")" -else - echo "registryctl installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 - exit 1 -fi -actual_hash="${actual_hash%% *}" +verify_asset() { + local name="$1" + local expected_hash actual_hash + expected_hash="$(awk -v asset="$name" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" + if [ -z "$expected_hash" ]; then + echo "SHA256SUMS has no entry for $name" >&2 + exit 1 + fi + actual_hash="$(sha256_file "$tmpdir/$name")" + if [ "$actual_hash" != "$expected_hash" ]; then + echo "Checksum verification failed for $name" >&2 + echo "Expected: $expected_hash" >&2 + echo "Actual: $actual_hash" >&2 + exit 1 + fi +} -if [ "$actual_hash" != "$expected_hash" ]; then - echo "Checksum verification failed for $asset" >&2 - echo "Expected: $expected_hash" >&2 - echo "Actual: $actual_hash" >&2 - exit 1 +verify_asset "$asset" +if ((requires_image_lock)); then + verify_asset "$lock_asset" fi +if ((requires_image_lock)); then + printf 'Integrity checks passed: %s and %s matched SHA256SUMS.\n' "$asset" "$lock_asset" +else + printf 'Integrity check passed: %s matched SHA256SUMS.\n' "$asset" +fi cat < &str { + &self.images.registry_relay + } + + fn notary_image(&self) -> &str { + &self.images.registry_notary + } +} + +pub fn registryctl_image_lock_filename() -> String { + format!("registryctl-v{}-image-lock.json", env!("CARGO_PKG_VERSION")) +} + +/// Loads the release image lock located beside the running registryctl binary. +/// +/// Only project-generation commands call this function. Existing projects keep +/// using the immutable image references already stored in their generated files. +pub fn load_registryctl_image_lock() -> Result { + if let Some(path) = std::env::var_os(IMAGE_LOCK_PATH_ENV) { + return load_registryctl_image_lock_path(&PathBuf::from(path)); + } + let executable = + std::env::current_exe().context("failed to locate the running registryctl binary")?; + let directory = executable.parent().ok_or_else(|| { + anyhow!( + "running registryctl binary has no parent directory: {}", + executable.display() + ) + })?; + load_registryctl_image_lock_path(&directory.join(registryctl_image_lock_filename())) +} + +#[cfg(test)] +fn load_registryctl_image_lock_beside(executable: &Path) -> Result { + let directory = executable.parent().ok_or_else(|| { + anyhow!( + "running registryctl binary has no parent directory: {}", + executable.display() + ) + })?; + load_registryctl_image_lock_path(&directory.join(registryctl_image_lock_filename())) +} + +fn load_registryctl_image_lock_path(path: &Path) -> Result { + let guidance = format!( + "reinstall registryctl v{} with its matching image lock, or set {IMAGE_LOCK_PATH_ENV} to that verified file; verify the release evidence described at {REGISTRYCTL_VERIFY_GUIDE}", + env!("CARGO_PKG_VERSION") + ); + let metadata = fs::symlink_metadata(path).with_context(|| { + format!( + "registryctl image lock is missing at {}; {guidance}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + bail!( + "registryctl image lock must be a regular file, not a symlink or directory: {}; {guidance}", + path.display() + ); + } + if metadata.len() > IMAGE_LOCK_MAX_BYTES { + bail!( + "registryctl image lock exceeds the {IMAGE_LOCK_MAX_BYTES}-byte limit: {}; {guidance}", + path.display() + ); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + fs::File::open(path) + .with_context(|| { + format!( + "failed to open registryctl image lock {}; {guidance}", + path.display() + ) + })? + .take(IMAGE_LOCK_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .with_context(|| { + format!( + "failed to read registryctl image lock {}; {guidance}", + path.display() + ) + })?; + if bytes.len() as u64 > IMAGE_LOCK_MAX_BYTES { + bail!( + "registryctl image lock exceeds the {IMAGE_LOCK_MAX_BYTES}-byte limit: {}; {guidance}", + path.display() + ); + } + + let image_lock: RegistryctlImageLock = serde_json::from_slice(&bytes).with_context(|| { + format!( + "registryctl image lock is not valid schema-v1 JSON: {}; {guidance}", + path.display() + ) + })?; + validate_registryctl_image_lock(&image_lock).with_context(|| { + format!( + "registryctl image lock validation failed for {}; {guidance}", + path.display() + ) + })?; + Ok(image_lock) +} + +fn validate_registryctl_image_lock(image_lock: &RegistryctlImageLock) -> Result<()> { + if image_lock.schema_version != IMAGE_LOCK_SCHEMA_VERSION { + bail!( + "schema_version must be {IMAGE_LOCK_SCHEMA_VERSION:?}, got {:?}", + image_lock.schema_version + ); + } + let expected_release_tag = format!("v{}", env!("CARGO_PKG_VERSION")); + if image_lock.release_tag != expected_release_tag { + bail!( + "release_tag must exactly match registryctl version {expected_release_tag:?}, got {:?}", + image_lock.release_tag + ); + } + validate_lowercase_commit("manifest_source_ref", &image_lock.manifest_source_ref)?; + validate_lowercase_commit("tag_target", &image_lock.tag_target)?; + if image_lock.platform != LINUX_AMD64_PLATFORM { + bail!( + "platform must be {LINUX_AMD64_PLATFORM:?}, got {:?}", + image_lock.platform + ); + } + validate_locked_image_ref( + "images.registry-relay", + &image_lock.images.registry_relay, + RELAY_IMAGE_REPOSITORY, + )?; + validate_locked_image_ref( + "images.registry-notary", + &image_lock.images.registry_notary, + NOTARY_IMAGE_REPOSITORY, + )?; + Ok(()) +} + +fn validate_lowercase_commit(field: &str, value: &str) -> Result<()> { + if value.len() != 40 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + bail!("{field} must contain exactly 40 lowercase hexadecimal characters"); + } + Ok(()) +} + +fn validate_locked_image_ref(field: &str, value: &str, repository: &str) -> Result<()> { + let prefix = format!("{repository}@sha256:"); + let digest = value.strip_prefix(&prefix).ok_or_else(|| { + anyhow!("{field} must use the literal repository {repository:?} and a sha256 digest") + })?; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + bail!("{field} digest must contain exactly 64 lowercase hexadecimal characters"); + } + Ok(()) +} + #[derive(Clone, Copy, Debug, ValueEnum)] pub enum NotarySource { LocalRelay, @@ -920,19 +1108,32 @@ fn signing_algorithm_label(algorithm: SigningAlgorithm) -> &'static str { } } -pub fn init_spreadsheet_api(dir: &Path, sample: Sample) -> Result<()> { +pub fn init_spreadsheet_api( + dir: &Path, + sample: Sample, + image_lock: &RegistryctlImageLock, +) -> Result<()> { match sample { - Sample::Benefits => init_benefits_project(dir), + Sample::Benefits => init_benefits_project(dir, image_lock), } } -pub fn init_notary_project(dir: &Path, options: NotaryInitOptions) -> Result<()> { - init_standalone_notary_project(dir, options) +pub fn init_notary_project( + dir: &Path, + options: NotaryInitOptions, + image_lock: &RegistryctlImageLock, +) -> Result<()> { + init_standalone_notary_project(dir, options, image_lock) } -pub fn add_notary(project_dir: &Path, from: NotarySource, force: bool) -> Result<()> { +pub fn add_notary( + project_dir: &Path, + from: NotarySource, + force: bool, + image_lock: &RegistryctlImageLock, +) -> Result<()> { match from { - NotarySource::LocalRelay => add_notary_from_local_relay(project_dir, force), + NotarySource::LocalRelay => add_notary_from_local_relay(project_dir, force, image_lock), } } @@ -1968,7 +2169,7 @@ impl SecretRedactor { } } -fn init_benefits_project(dir: &Path) -> Result<()> { +fn init_benefits_project(dir: &Path, image_lock: &RegistryctlImageLock) -> Result<()> { if dir.exists() { let mut entries = fs::read_dir(dir).with_context(|| format!("failed to inspect {}", dir.display()))?; @@ -1990,9 +2191,9 @@ fn init_benefits_project(dir: &Path) -> Result<()> { let credentials = LocalCredentials::generate()?; write_text( dir.join("registryctl.yaml"), - ®istryctl_manifest(dir, ProjectManifestKind::Relay)?, + ®istryctl_manifest(dir, ProjectManifestKind::Relay, image_lock)?, )?; - write_text(dir.join("compose.yaml"), &compose_yaml(false))?; + write_text(dir.join("compose.yaml"), &compose_yaml(false, image_lock))?; write_text(dir.join("README.md"), project_readme())?; write_text(dir.join(".gitignore"), include_str!("templates/gitignore"))?; write_text(dir.join("relay/config.yaml"), &relay_config(&credentials))?; @@ -2003,7 +2204,11 @@ fn init_benefits_project(dir: &Path) -> Result<()> { Ok(()) } -fn init_standalone_notary_project(dir: &Path, options: NotaryInitOptions) -> Result<()> { +fn init_standalone_notary_project( + dir: &Path, + options: NotaryInitOptions, + image_lock: &RegistryctlImageLock, +) -> Result<()> { if dir.exists() { let mut entries = fs::read_dir(dir).with_context(|| format!("failed to inspect {}", dir.display()))?; @@ -2043,11 +2248,12 @@ fn init_standalone_notary_project(dir: &Path, options: NotaryInitOptions) -> Res ®istryctl_manifest( dir, ProjectManifestKind::StandaloneNotary { options: &options }, + image_lock, )?, )?; write_text( dir.join("compose.yaml"), - &compose_notary_only_yaml(options.source_network.as_deref()), + &compose_notary_only_yaml(options.source_network.as_deref(), image_lock), )?; write_text(dir.join("README.md"), standalone_notary_readme())?; write_text(dir.join(".gitignore"), include_str!("templates/gitignore"))?; @@ -2064,7 +2270,11 @@ fn init_standalone_notary_project(dir: &Path, options: NotaryInitOptions) -> Res Ok(()) } -fn add_notary_from_local_relay(project_dir: &Path, force: bool) -> Result<()> { +fn add_notary_from_local_relay( + project_dir: &Path, + force: bool, + image_lock: &RegistryctlImageLock, +) -> Result<()> { let project = Project::load(project_dir)?; let notary_config_path = project_dir.join("notary/config.yaml"); if project.notary.is_some() && !force { @@ -2099,9 +2309,16 @@ fn add_notary_from_local_relay(project_dir: &Path, force: bool) -> Result<()> { )?; write_text( project_dir.join("registryctl.yaml"), - ®istryctl_manifest(project_dir, ProjectManifestKind::RelayWithNotary)?, + ®istryctl_manifest( + project_dir, + ProjectManifestKind::RelayWithNotary, + image_lock, + )?, + )?; + write_text( + project_dir.join("compose.yaml"), + &compose_yaml(true, image_lock), )?; - write_text(project_dir.join("compose.yaml"), &compose_yaml(true))?; write_text( secrets_path, &upsert_env_values(&secrets_contents, ¬ary_credentials.env_values()), @@ -4169,8 +4386,16 @@ fn compose_platform_override( } fn project_uses_amd64_only_release_image(project: &Project) -> bool { - project.runtime.relay_image.as_deref() == Some(RELAY_IMAGE) - || project.runtime.notary_image.as_deref() == Some(NOTARY_IMAGE) + project + .runtime + .relay_image + .as_deref() + .is_some_and(|image| image.starts_with(&format!("{RELAY_IMAGE_REPOSITORY}@sha256:"))) + || project + .runtime + .notary_image + .as_deref() + .is_some_and(|image| image.starts_with(&format!("{NOTARY_IMAGE_REPOSITORY}@sha256:"))) } fn is_linux_arm64_platform(platform: &str) -> bool { @@ -4523,7 +4748,11 @@ enum ProjectManifestKind<'a> { StandaloneNotary { options: &'a NotaryInitOptions }, } -fn registryctl_manifest(dir: &Path, kind: ProjectManifestKind<'_>) -> Result { +fn registryctl_manifest( + dir: &Path, + kind: ProjectManifestKind<'_>, + image_lock: &RegistryctlImageLock, +) -> Result { let name = dir .file_name() .and_then(|name| name.to_str()) @@ -4588,9 +4817,9 @@ fn registryctl_manifest(dir: &Path, kind: ProjectManifestKind<'_>) -> Result) -> Result String { +fn compose_yaml(include_notary: bool, image_lock: &RegistryctlImageLock) -> String { if include_notary { include_str!("templates/compose-with-notary.yaml") + .replace("{{relay_image}}", image_lock.relay_image()) + .replace("{{notary_image}}", image_lock.notary_image()) .replace("{{notary_redis_image}}", NOTARY_REDIS_IMAGE) } else { - include_str!("templates/compose.yaml").to_string() + include_str!("templates/compose.yaml").replace("{{relay_image}}", image_lock.relay_image()) } } -fn compose_notary_only_yaml(source_network: Option<&str>) -> String { +fn compose_notary_only_yaml( + source_network: Option<&str>, + image_lock: &RegistryctlImageLock, +) -> String { let (service_networks, networks) = match source_network { Some(name) => ( " networks:\n - default\n - source_api\n", @@ -4625,6 +4859,7 @@ fn compose_notary_only_yaml(source_network: Option<&str>) -> String { None => ("", String::new()), }; include_str!("templates/compose-notary.yaml") + .replace("{{notary_image}}", image_lock.notary_image()) .replace("{{notary_redis_image}}", NOTARY_REDIS_IMAGE) .replace("{{source_network_service}}", service_networks) .replace("{{source_networks}}", &networks) @@ -5121,6 +5356,194 @@ mod tests { use super::*; const TEST_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"registryctl-test-private-key"}"#; + const TEST_RELAY_IMAGE: &str = "ghcr.io/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const TEST_NOTARY_IMAGE: &str = "ghcr.io/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn test_image_lock() -> RegistryctlImageLock { + RegistryctlImageLock { + schema_version: IMAGE_LOCK_SCHEMA_VERSION.to_string(), + release_tag: format!("v{}", env!("CARGO_PKG_VERSION")), + manifest_source_ref: "a".repeat(40), + tag_target: "b".repeat(40), + platform: LINUX_AMD64_PLATFORM.to_string(), + images: RegistryctlLockedImages { + registry_relay: TEST_RELAY_IMAGE.to_string(), + registry_notary: TEST_NOTARY_IMAGE.to_string(), + }, + } + } + + fn test_image_lock_json() -> serde_json::Value { + serde_json::json!({ + "schema_version": IMAGE_LOCK_SCHEMA_VERSION, + "release_tag": format!("v{}", env!("CARGO_PKG_VERSION")), + "manifest_source_ref": "a".repeat(40), + "tag_target": "b".repeat(40), + "platform": LINUX_AMD64_PLATFORM, + "images": { + "registry-relay": TEST_RELAY_IMAGE, + "registry-notary": TEST_NOTARY_IMAGE, + } + }) + } + + fn write_test_image_lock(temp: &TempDir, value: &serde_json::Value) -> PathBuf { + let executable = temp.path().join("registryctl"); + fs::write(&executable, b"test binary").unwrap(); + fs::write( + temp.path().join(registryctl_image_lock_filename()), + serde_json::to_vec(value).unwrap(), + ) + .unwrap(); + executable + } + + #[test] + fn image_lock_loads_strict_versioned_file_beside_executable() { + let temp = TempDir::new().unwrap(); + let executable = write_test_image_lock(&temp, &test_image_lock_json()); + + let image_lock = load_registryctl_image_lock_beside(&executable).unwrap(); + + assert_eq!(image_lock, test_image_lock()); + } + + #[test] + fn image_lock_rejects_unknown_root_and_image_fields() { + for (field_path, value) in [ + ("root", serde_json::json!(true)), + ("images", serde_json::json!(true)), + ] { + let temp = TempDir::new().unwrap(); + let mut document = test_image_lock_json(); + if field_path == "root" { + document["unexpected"] = value; + } else { + document["images"]["unexpected"] = value; + } + let executable = write_test_image_lock(&temp, &document); + + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + + assert!( + format!("{error:#}").contains("unknown field"), + "unexpected error: {error:#}" + ); + } + } + + #[test] + fn image_lock_rejects_release_identity_and_platform_mismatches() { + for (field, invalid, expected) in [ + ("release_tag", serde_json::json!("v9.9.9"), "release_tag"), + ( + "manifest_source_ref", + serde_json::json!("A".repeat(40)), + "manifest_source_ref", + ), + ( + "tag_target", + serde_json::json!("b".repeat(39)), + "tag_target", + ), + ("platform", serde_json::json!("linux/arm64"), "platform"), + ] { + let temp = TempDir::new().unwrap(); + let mut document = test_image_lock_json(); + document[field] = invalid; + let executable = write_test_image_lock(&temp, &document); + + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + + assert!( + format!("{error:#}").contains(expected), + "unexpected error: {error:#}" + ); + } + } + + #[test] + fn image_lock_rejects_mutable_or_noncanonical_image_references() { + for (field, invalid) in [ + ( + "registry-relay", + "ghcr.io/registrystack/registry-relay:v0.8.4".to_string(), + ), + ( + "registry-notary", + format!("ghcr.io/example/registry-notary@sha256:{}", "b".repeat(64)), + ), + ( + "registry-relay", + format!( + "ghcr.io/registrystack/registry-relay@sha256:{}", + "A".repeat(64) + ), + ), + ] { + let temp = TempDir::new().unwrap(); + let mut document = test_image_lock_json(); + document["images"][field] = serde_json::json!(invalid); + let executable = write_test_image_lock(&temp, &document); + + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + + assert!( + format!("{error:#}").contains(&format!("images.{field}")), + "unexpected error: {error:#}" + ); + } + } + + #[test] + fn image_lock_rejects_missing_nonregular_and_oversized_files() { + let missing = TempDir::new().unwrap(); + let missing_executable = missing.path().join("registryctl"); + fs::write(&missing_executable, b"test binary").unwrap(); + let error = load_registryctl_image_lock_beside(&missing_executable).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("image lock is missing")); + assert!(message.contains(IMAGE_LOCK_PATH_ENV)); + + let directory = TempDir::new().unwrap(); + let executable = directory.path().join("registryctl"); + fs::write(&executable, b"test binary").unwrap(); + fs::create_dir(directory.path().join(registryctl_image_lock_filename())).unwrap(); + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + assert!(format!("{error:#}").contains("must be a regular file")); + + let oversized = TempDir::new().unwrap(); + let executable = oversized.path().join("registryctl"); + fs::write(&executable, b"test binary").unwrap(); + fs::write( + oversized.path().join(registryctl_image_lock_filename()), + vec![b' '; IMAGE_LOCK_MAX_BYTES as usize + 1], + ) + .unwrap(); + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + assert!(format!("{error:#}").contains("exceeds the 16384-byte limit")); + } + + #[cfg(unix)] + #[test] + fn image_lock_rejects_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let executable = temp.path().join("registryctl"); + let target = temp.path().join("lock-target.json"); + fs::write(&executable, b"test binary").unwrap(); + fs::write( + &target, + serde_json::to_vec(&test_image_lock_json()).unwrap(), + ) + .unwrap(); + symlink(&target, temp.path().join(registryctl_image_lock_filename())).unwrap(); + + let error = load_registryctl_image_lock_beside(&executable).unwrap_err(); + + assert!(format!("{error:#}").contains("must be a regular file")); + } #[test] fn config_bundle_sign_anchor_and_verify_round_trip() { @@ -5810,7 +6233,7 @@ workflows: let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); for path in [ "registryctl.yaml", @@ -5898,7 +6321,7 @@ workflows: fn bruno_files_for_relay_project_are_generated_and_secret_scoped() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let local_bru = @@ -5939,8 +6362,14 @@ workflows: fn bruno_generation_after_notary_add_includes_notary_requests_without_raw_keys() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let local_bru = @@ -5970,7 +6399,7 @@ workflows: fn bruno_generate_is_idempotent_for_generated_files() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let before = fs::read_to_string(project.join("bruno/registry-api/Relay/Health.bru")).unwrap(); @@ -6000,6 +6429,7 @@ workflows: source_claim_title: "Benefits person exists".to_string(), smoke_target_id: "per-2001".to_string(), }, + &test_image_lock(), ) .unwrap(); @@ -6043,7 +6473,7 @@ workflows: .unwrap(); assert!(manifest.get("relay").is_none()); assert_eq!(manifest["project"]["kind"], "notary"); - assert_eq!(manifest["runtime"]["notary_image"], NOTARY_IMAGE); + assert_eq!(manifest["runtime"]["notary_image"], TEST_NOTARY_IMAGE); assert_eq!(manifest["runtime"]["notary_base_url"], NOTARY_BASE_URL); assert_eq!(manifest["notary"]["source"], "registry_data_api"); assert_eq!( @@ -6102,6 +6532,7 @@ workflows: source_claim_title: "Patient record exists".to_string(), smoke_target_id: "person-123".to_string(), }, + &test_image_lock(), ) .unwrap(); @@ -6197,6 +6628,7 @@ workflows: source_claim_title: "OpenCRVS birth record exists".to_string(), smoke_target_id: "UIN-2001".to_string(), }, + &test_image_lock(), ) .unwrap(); @@ -6310,7 +6742,7 @@ workflows: fn manifest_pins_image_and_records_base_url() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let manifest: Value = serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) @@ -6323,7 +6755,7 @@ workflows: ); assert_eq!(manifest["runtime"]["relay_base_url"], RELAY_BASE_URL); assert!(manifest["relay"].get("metadata").is_none()); - assert!(compose.contains(&format!("image: {RELAY_IMAGE}"))); + assert!(compose.contains(&format!("image: {TEST_RELAY_IMAGE}"))); assert!(!compose.contains("metadata.yaml")); assert!(!compose.contains("registry-relay:snapshot")); assert!(!compose.contains("registry-relay:latest")); @@ -6333,7 +6765,7 @@ workflows: fn compose_platform_override_targets_amd64_for_arm64_relay_project() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let project = Project::load(&project_dir).unwrap(); assert_eq!( @@ -6354,7 +6786,7 @@ workflows: fn compose_platform_override_respects_operator_platform() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let project = Project::load(&project_dir).unwrap(); assert_eq!( @@ -6367,7 +6799,8 @@ workflows: fn compose_platform_override_targets_amd64_for_arm64_notary_project() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-notary"); - init_standalone_notary_project(&project_dir, default_notary_options()).unwrap(); + init_standalone_notary_project(&project_dir, default_notary_options(), &test_image_lock()) + .unwrap(); let project = Project::load(&project_dir).unwrap(); assert_eq!( @@ -6380,7 +6813,7 @@ workflows: fn relay_only_manifest_loads_without_notary_section() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); Project::load(&project).unwrap(); @@ -6580,8 +7013,14 @@ workflows: fn manifest_after_notary_add_records_relay_plus_notary() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let manifest: Value = serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) @@ -6613,8 +7052,14 @@ workflows: fn relay_plus_notary_local_demo_has_no_external_auth_dependencies() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); for path in [ "registryctl.yaml", @@ -6663,12 +7108,18 @@ workflows: fn add_notary_backfills_relay_state_for_older_generated_projects() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); fs::remove_dir_all(project.join("state")).unwrap(); fs::remove_file(project.join(".env")).unwrap(); fs::write(project.join(".gitignore"), "secrets/\ncustom-output/\n").unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); for path in [ "state/relay/cache", @@ -6709,13 +7160,19 @@ workflows: fn compose_after_notary_add_includes_digest_pinned_notary_service() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let compose = fs::read_to_string(project.join("compose.yaml")).unwrap(); assert!(compose.contains("registry-notary:")); - assert!(compose.contains(&format!("image: {NOTARY_IMAGE}"))); + assert!(compose.contains(&format!("image: {TEST_NOTARY_IMAGE}"))); assert!(!compose.contains("registry-notary:snapshot")); assert!(!compose.contains("registry-notary:latest")); assert!(compose.contains("registry-notary-redis:")); @@ -6735,8 +7192,14 @@ workflows: fn notary_config_after_add_uses_local_relay_registry_data_api() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let notary_config_path = project.join("notary/config.yaml"); @@ -6826,8 +7289,14 @@ workflows: fn local_relay_notary_config_permits_tutorial_purpose() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let notary_config = fs::read_to_string(project.join("notary/config.yaml")).unwrap(); let parsed_config: registry_notary_core::StandaloneRegistryNotaryConfig = @@ -6854,7 +7323,8 @@ workflows: fn standalone_notary_config_permits_tutorial_purpose() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-notary"); - init_standalone_notary_project(&project, default_notary_options()).unwrap(); + init_standalone_notary_project(&project, default_notary_options(), &test_image_lock()) + .unwrap(); let notary_config = fs::read_to_string(project.join("notary/config.yaml")).unwrap(); let parsed_config: registry_notary_core::StandaloneRegistryNotaryConfig = @@ -6879,8 +7349,14 @@ workflows: fn local_env_after_notary_add_appends_notary_and_source_tokens() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); - add_notary(&project, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let local_env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let notary_config_path = project.join("notary/config.yaml"); @@ -6910,12 +7386,18 @@ workflows: fn add_notary_refuses_to_overwrite_existing_notary_files() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); fs::create_dir_all(project.join("notary")).unwrap(); let marker_path = project.join("notary/config.yaml"); fs::write(&marker_path, "user-owned notary config\n").unwrap(); - let error = add_notary(&project, NotarySource::LocalRelay, false).unwrap_err(); + let error = add_notary( + &project, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap_err(); assert!( error.to_string().contains("notary/config.yaml") @@ -6932,8 +7414,14 @@ workflows: fn notary_smoke_project_writes_redacted_failure_report() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); - add_notary(&project_dir, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project_dir, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let error = notary_smoke_project(&project_dir).unwrap_err(); assert!(error @@ -6955,7 +7443,7 @@ workflows: fn generated_gitignore_excludes_local_secrets_and_output() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let gitignore = fs::read_to_string(project.join(".gitignore")).unwrap(); assert!(gitignore.lines().any(|line| line == ".env")); @@ -6968,7 +7456,7 @@ workflows: fn generated_credentials_reference_fingerprints_without_commitments() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let config = fs::read_to_string(project.join("relay/config.yaml")).unwrap(); @@ -6997,7 +7485,7 @@ workflows: fn generated_fingerprint_preflight_passes_for_clean_project() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let project = Project::load(&project_dir).unwrap(); validate_project_fingerprints(&project_dir, &project).unwrap(); @@ -7012,7 +7500,7 @@ workflows: ] { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let env_path = project_dir.join("secrets/local.env"); let mut env = fs::read_to_string(&env_path).unwrap(); @@ -7040,7 +7528,7 @@ workflows: ] { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let env_path = project_dir.join("secrets/local.env"); let env = fs::read_to_string(&env_path).unwrap(); @@ -7063,7 +7551,7 @@ workflows: fn generated_public_files_do_not_contain_raw_keys_or_fingerprints() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let secrets: BTreeSet<_> = env @@ -7093,7 +7581,7 @@ workflows: fn generated_workbook_is_xlsx_with_benefits_sample_sheets() { let temp = TempDir::new().unwrap(); let project = temp.path().join("my-first-api"); - init_spreadsheet_api(&project, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let workbook = fs::read(project.join("data/benefits_casework.xlsx")).unwrap(); assert!(workbook.starts_with(b"PK")); @@ -7181,7 +7669,7 @@ workflows: fn smoke_project_writes_redacted_failure_report() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let error = smoke_project(&project_dir).unwrap_err(); assert!(error @@ -7200,7 +7688,7 @@ workflows: fn doctor_invokes_relay_product_for_relay_project() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let fake_bin = temp.path().join("bin"); fs::create_dir_all(&fake_bin).unwrap(); write_fake_product( @@ -7246,7 +7734,7 @@ workflows: fn doctor_invokes_relay_product_with_profile_override() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let fake_bin = temp.path().join("bin"); fs::create_dir_all(&fake_bin).unwrap(); write_fake_product( @@ -7286,8 +7774,14 @@ workflows: fn doctor_invokes_relay_and_notary_for_combined_project_with_profile_override() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); - add_notary(&project_dir, NotarySource::LocalRelay, false).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); + add_notary( + &project_dir, + NotarySource::LocalRelay, + false, + &test_image_lock(), + ) + .unwrap(); let fake_bin = temp.path().join("bin"); fs::create_dir_all(&fake_bin).unwrap(); write_fake_product( @@ -7338,7 +7832,7 @@ workflows: fn doctor_invokes_only_notary_for_standalone_notary_project() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("notary-only"); - init_notary_project(&project_dir, default_notary_options()).unwrap(); + init_notary_project(&project_dir, default_notary_options(), &test_image_lock()).unwrap(); let fake_bin = temp.path().join("bin"); fs::create_dir_all(&fake_bin).unwrap(); write_fake_product( @@ -7365,7 +7859,7 @@ workflows: fn doctor_reports_missing_product_binary_without_panic() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let empty_path = temp.path().join("empty-path"); fs::create_dir_all(&empty_path).unwrap(); @@ -7384,7 +7878,7 @@ workflows: fn doctor_reports_nonzero_product_exit_and_redacts_output() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let env = fs::read_to_string(project_dir.join("secrets/local.env")).unwrap(); let secrets = env .lines() @@ -7456,7 +7950,7 @@ workflows: fn doctor_extracts_structured_product_report_and_findings_after_redaction() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let env = fs::read_to_string(project_dir.join("secrets/local.env")).unwrap(); let secret = env .lines() @@ -7512,7 +8006,7 @@ workflows: fn doctor_extracts_notary_diagnostics_as_product_findings() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("notary-only"); - init_notary_project(&project_dir, default_notary_options()).unwrap(); + init_notary_project(&project_dir, default_notary_options(), &test_image_lock()).unwrap(); let product_json = serde_json::json!({ "schema_version": "registry.config.diagnostic_report.v1", "product": "registry-notary", @@ -7563,7 +8057,7 @@ workflows: // silently dropped even though the product emitted it. let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let product_json = serde_json::json!({ "schema_version": "registry.config.diagnostic_report.v1", "product": "registry-relay", @@ -7610,7 +8104,7 @@ workflows: fn doctor_report_json_has_registryctl_schema() { let temp = TempDir::new().unwrap(); let project_dir = temp.path().join("my-first-api"); - init_spreadsheet_api(&project_dir, Sample::Benefits).unwrap(); + init_spreadsheet_api(&project_dir, Sample::Benefits, &test_image_lock()).unwrap(); let fake_bin = temp.path().join("bin"); fs::create_dir_all(&fake_bin).unwrap(); write_fake_product( diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 380b7e7c..4f20f453 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -16,60 +16,70 @@ fn main() -> Result<()> { match cli.command { Commands::UpdateCheck => registryctl::update_check(env!("CARGO_PKG_VERSION"))?, Commands::UpdateCheckRefresh => registryctl::refresh_update_check_cache()?, - Commands::Init { command } => match *command { - InitCommand::Relay { dir, sample } => { - registryctl::init_spreadsheet_api(&dir, sample)?; + Commands::Init { command } => { + let image_lock = registryctl::load_registryctl_image_lock()?; + match *command { + InitCommand::Relay { dir, sample } => { + registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?; + } + InitCommand::SpreadsheetApi { dir, sample } => { + registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?; + } + InitCommand::Notary { + dir, + source_kind, + source_url, + source_token_from_env, + source_token_env, + source_dataset, + source_entity, + source_lookup_field, + source_network, + source_claim, + source_claim_title, + smoke_target_id, + } => { + registryctl::init_notary_project( + &dir, + NotaryInitOptions { + source_kind, + source_url: source_url + .unwrap_or_else(|| source_kind.default_source_url().to_string()), + source_token_from_env, + source_token_env: source_token_env.unwrap_or_else(|| { + source_kind.default_source_token_env().to_string() + }), + source_dataset: source_dataset.unwrap_or_else(|| { + source_kind.default_source_dataset().to_string() + }), + source_entity: source_entity + .unwrap_or_else(|| source_kind.default_source_entity().to_string()), + source_lookup_field: source_lookup_field.unwrap_or_else(|| { + source_kind.default_source_lookup_field().to_string() + }), + source_network, + source_claim: source_claim + .unwrap_or_else(|| source_kind.default_source_claim().to_string()), + source_claim_title: source_claim_title.unwrap_or_else(|| { + source_kind.default_source_claim_title().to_string() + }), + smoke_target_id: smoke_target_id.unwrap_or_else(|| { + source_kind.default_smoke_target_id().to_string() + }), + }, + &image_lock, + )?; + } } - InitCommand::SpreadsheetApi { dir, sample } => { - registryctl::init_spreadsheet_api(&dir, sample)?; - } - InitCommand::Notary { - dir, - source_kind, - source_url, - source_token_from_env, - source_token_env, - source_dataset, - source_entity, - source_lookup_field, - source_network, - source_claim, - source_claim_title, - smoke_target_id, - } => { - registryctl::init_notary_project( - &dir, - NotaryInitOptions { - source_kind, - source_url: source_url - .unwrap_or_else(|| source_kind.default_source_url().to_string()), - source_token_from_env, - source_token_env: source_token_env - .unwrap_or_else(|| source_kind.default_source_token_env().to_string()), - source_dataset: source_dataset - .unwrap_or_else(|| source_kind.default_source_dataset().to_string()), - source_entity: source_entity - .unwrap_or_else(|| source_kind.default_source_entity().to_string()), - source_lookup_field: source_lookup_field.unwrap_or_else(|| { - source_kind.default_source_lookup_field().to_string() - }), - source_network, - source_claim: source_claim - .unwrap_or_else(|| source_kind.default_source_claim().to_string()), - source_claim_title: source_claim_title.unwrap_or_else(|| { - source_kind.default_source_claim_title().to_string() - }), - smoke_target_id: smoke_target_id - .unwrap_or_else(|| source_kind.default_smoke_target_id().to_string()), - }, - )?; - } - }, - Commands::Add { command } => match command { - AddCommand::Notary { from, force } => { - registryctl::add_notary(&std::env::current_dir()?, from, force)?; + } + Commands::Add { command } => { + let image_lock = registryctl::load_registryctl_image_lock()?; + match command { + AddCommand::Notary { from, force } => { + registryctl::add_notary(&std::env::current_dir()?, from, force, &image_lock)?; + } } - }, + } Commands::Start => registryctl::start_project(&std::env::current_dir()?)?, Commands::Stop => registryctl::stop_project(&std::env::current_dir()?)?, Commands::Restart => registryctl::restart_project(&std::env::current_dir()?)?, diff --git a/crates/registryctl/src/templates/compose-notary.yaml b/crates/registryctl/src/templates/compose-notary.yaml index 43679944..1906a7fd 100644 --- a/crates/registryctl/src/templates/compose-notary.yaml +++ b/crates/registryctl/src/templates/compose-notary.yaml @@ -1,7 +1,7 @@ # Generated by registryctl. services: registry-notary: - image: ghcr.io/registrystack/registry-notary@sha256:338d3ac7ddbea55f6e76014b9c23e4ff4e7206c2c40e356452288de06a745ff3 + image: {{notary_image}} user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" env_file: - ./secrets/local.env diff --git a/crates/registryctl/src/templates/compose-with-notary.yaml b/crates/registryctl/src/templates/compose-with-notary.yaml index e72c927b..2cc38ff7 100644 --- a/crates/registryctl/src/templates/compose-with-notary.yaml +++ b/crates/registryctl/src/templates/compose-with-notary.yaml @@ -1,7 +1,7 @@ # Generated by registryctl. services: registry-relay: - image: ghcr.io/registrystack/registry-relay@sha256:da9332ef30ba252d54ba275eb2a9be443a65e95ef78c493b9b0ce30cbc391e71 + image: {{relay_image}} user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" env_file: - ./secrets/local.env @@ -15,7 +15,7 @@ services: - ./state/relay/audit:/var/log/registry-relay registry-notary: - image: ghcr.io/registrystack/registry-notary@sha256:338d3ac7ddbea55f6e76014b9c23e4ff4e7206c2c40e356452288de06a745ff3 + image: {{notary_image}} user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" env_file: - ./secrets/local.env diff --git a/crates/registryctl/src/templates/compose.yaml b/crates/registryctl/src/templates/compose.yaml index cbff9fc6..2da0228d 100644 --- a/crates/registryctl/src/templates/compose.yaml +++ b/crates/registryctl/src/templates/compose.yaml @@ -1,7 +1,7 @@ # Generated by registryctl. services: registry-relay: - image: ghcr.io/registrystack/registry-relay@sha256:da9332ef30ba252d54ba275eb2a9be443a65e95ef78c493b9b0ce30cbc391e71 + image: {{relay_image}} user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" env_file: - ./secrets/local.env diff --git a/crates/registryctl/tests/image_lock.rs b/crates/registryctl/tests/image_lock.rs new file mode 100644 index 00000000..b3fbf4a3 --- /dev/null +++ b/crates/registryctl/tests/image_lock.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::process::Command; + +use serde_json::json; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +const RELAY_IMAGE: &str = "ghcr.io/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const NOTARY_IMAGE: &str = "ghcr.io/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn write_image_lock(temp: &TempDir) -> std::path::PathBuf { + let path = temp.path().join("release-image-lock.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": format!("v{}", env!("CARGO_PKG_VERSION")), + "manifest_source_ref": "a".repeat(40), + "tag_target": "b".repeat(40), + "platform": "linux/amd64", + "images": { + "registry-relay": RELAY_IMAGE, + "registry-notary": NOTARY_IMAGE, + } + })) + .unwrap(), + ) + .unwrap(); + path +} + +#[test] +fn init_uses_explicit_release_image_lock_without_registry_lookup() { + let temp = TempDir::new().unwrap(); + let image_lock = write_image_lock(&temp); + let project = temp.path().join("my-first-api"); + + let output = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .args([ + "init", + "relay", + project.to_str().unwrap(), + "--sample", + "benefits", + ]) + .env("REGISTRYCTL_IMAGE_LOCK", &image_lock) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .unwrap(); + + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let manifest = fs::read_to_string(project.join("registryctl.yaml")).unwrap(); + let compose = fs::read_to_string(project.join("compose.yaml")).unwrap(); + assert!(manifest.contains(RELAY_IMAGE)); + assert!(compose.contains(RELAY_IMAGE)); + assert!(!manifest.contains("registry-notary")); +} + +#[test] +fn missing_release_image_lock_fails_before_init_mutates_target() { + let temp = TempDir::new().unwrap(); + let missing_lock = temp.path().join("missing-image-lock.json"); + let project = temp.path().join("must-not-exist"); + + let output = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .args([ + "init", + "relay", + project.to_str().unwrap(), + "--sample", + "benefits", + ]) + .env("REGISTRYCTL_IMAGE_LOCK", &missing_lock) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!( + !project.exists(), + "init mutated the target before lock validation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("registryctl image lock is missing"), + "{stderr}" + ); + assert!(stderr.contains("REGISTRYCTL_IMAGE_LOCK"), "{stderr}"); +} + +#[test] +fn init_does_not_search_current_working_directory_for_image_lock() { + let temp = TempDir::new().unwrap(); + let explicit_lock = write_image_lock(&temp); + let cwd_lock = temp.path().join(format!( + "registryctl-v{}-image-lock.json", + env!("CARGO_PKG_VERSION") + )); + fs::rename(explicit_lock, &cwd_lock).unwrap(); + let project = temp.path().join("must-not-exist"); + + let output = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .args([ + "init", + "relay", + project.to_str().unwrap(), + "--sample", + "benefits", + ]) + .current_dir(temp.path()) + .env_remove("REGISTRYCTL_IMAGE_LOCK") + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(!project.exists()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("registryctl image lock is missing"), + "{stderr}" + ); + assert!(!stderr.contains(cwd_lock.to_str().unwrap()), "{stderr}"); +} + +#[cfg(unix)] +#[test] +fn existing_project_runtime_command_does_not_consult_image_lock() { + let temp = TempDir::new().unwrap(); + let image_lock = write_image_lock(&temp); + let project = temp.path().join("my-first-api"); + let init = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .args([ + "init", + "relay", + project.to_str().unwrap(), + "--sample", + "benefits", + ]) + .env("REGISTRYCTL_IMAGE_LOCK", &image_lock) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .unwrap(); + assert!(init.status.success()); + + let fake_bin = temp.path().join("fake-bin"); + fs::create_dir(&fake_bin).unwrap(); + let docker = fake_bin.join("docker"); + fs::write(&docker, b"#!/usr/bin/env sh\nexit 0\n").unwrap(); + fs::set_permissions(&docker, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!( + "{}:{}", + fake_bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let missing_lock = temp.path().join("missing-image-lock.json"); + + let output = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .arg("stop") + .current_dir(&project) + .env("PATH", path) + .env("REGISTRYCTL_IMAGE_LOCK", missing_lock) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stderr).contains("image lock")); +} diff --git a/crates/registryctl/tests/install_script.rs b/crates/registryctl/tests/install_script.rs index 2393fc33..9613bbc5 100644 --- a/crates/registryctl/tests/install_script.rs +++ b/crates/registryctl/tests/install_script.rs @@ -1,8 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use tempfile::TempDir; + +const TEST_VERSION: &str = "v9.8.7"; +const TEST_LOCK_ASSET: &str = "registryctl-v9.8.7-image-lock.json"; + #[test] fn installer_rejects_shell_active_and_noncanonical_release_tags() { let installer = Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh"); @@ -32,3 +41,463 @@ fn installer_rejects_shell_active_and_noncanonical_release_tags() { ); } } + +#[test] +fn installer_help_describes_version_aware_release_assets() { + let installer = Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh"); + let output = Command::new("bash") + .arg(installer) + .arg("--help") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(output.status.success()); + assert!( + stdout.contains("Releases before v0.9.0 install the binary."), + "{stdout}" + ); + assert!( + stdout.contains("Releases v0.9.0 and later install"), + "{stdout}" + ); + assert!(stdout.contains("matching release image lock"), "{stdout}"); +} + +#[cfg(unix)] +#[test] +fn installer_preserves_binary_only_compatibility_for_v0_8_4() { + let fixture = InstallerFixture::for_release("v0.8.4", false); + let output = fixture.run(); + + assert!( + output.status.success(), + "legacy installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + b"registryctl release binary\n", + fs::read(fixture.install_dir.join("registryctl")) + .unwrap() + .as_slice() + ); + assert!(!fixture + .install_dir + .join("registryctl-v0.8.4-image-lock.json") + .exists()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout + .contains("Integrity check passed: registryctl-v0.8.4-linux-amd64 matched SHA256SUMS."), + "{stdout}" + ); + assert!(!stdout.contains("release image lock installed"), "{stdout}"); + let downloads = fs::read_to_string(fixture.fake_curl_log()).unwrap(); + assert!(downloads.contains("/releases/download/v0.8.4/registryctl-v0.8.4-linux-amd64")); + assert!(downloads.contains("/releases/download/v0.8.4/SHA256SUMS")); + assert!(!downloads.contains("image-lock"), "{downloads}"); +} + +#[cfg(unix)] +#[test] +fn installer_checksum_verifies_and_installs_binary_with_matching_lock() { + let fixture = InstallerFixture::new(); + let output = fixture.run(); + + assert!( + output.status.success(), + "installer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + b"registryctl release binary\n", + fs::read(fixture.install_dir.join("registryctl")) + .unwrap() + .as_slice() + ); + assert_eq!( + b"registryctl release image lock\n", + fs::read(fixture.install_dir.join(TEST_LOCK_ASSET)) + .unwrap() + .as_slice() + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("matched SHA256SUMS"), "{stdout}"); + assert!( + stdout.contains("Authenticity check not performed by this installer."), + "{stdout}" + ); + assert!( + stdout.contains("Evidence availability varies by release, and v0.8.0 is unsigned."), + "{stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn installer_checksum_failure_preserves_existing_binary_and_lock() { + let fixture = InstallerFixture::new(); + fs::create_dir_all(&fixture.install_dir).unwrap(); + fs::write( + fixture.install_dir.join("registryctl"), + b"existing binary\n", + ) + .unwrap(); + fs::write( + fixture.install_dir.join(TEST_LOCK_ASSET), + b"existing lock\n", + ) + .unwrap(); + fs::write( + fixture.release_dir.join(TEST_LOCK_ASSET), + b"corrupted after checksums\n", + ) + .unwrap(); + + let output = fixture.run(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("Checksum verification failed for registryctl-v9.8.7-image-lock.json")); + assert_eq!( + b"existing binary\n", + fs::read(fixture.install_dir.join("registryctl")) + .unwrap() + .as_slice() + ); + assert_eq!( + b"existing lock\n", + fs::read(fixture.install_dir.join(TEST_LOCK_ASSET)) + .unwrap() + .as_slice() + ); +} + +#[cfg(unix)] +#[test] +fn installer_rolls_back_pair_when_binary_replacement_fails_after_lock_replacement() { + for existing in [false, true] { + let fixture = InstallerFixture::new(); + if existing { + fs::create_dir_all(&fixture.install_dir).unwrap(); + fs::write( + fixture.install_dir.join("registryctl"), + b"existing binary\n", + ) + .unwrap(); + fs::write( + fixture.install_dir.join(TEST_LOCK_ASSET), + b"existing lock\n", + ) + .unwrap(); + } + + let output = fixture.run_with_second_mv_failure(); + + assert!(!output.status.success()); + let binary_path = fixture.install_dir.join("registryctl"); + let lock_path = fixture.install_dir.join(TEST_LOCK_ASSET); + if existing { + assert_eq!( + b"existing binary\n", + fs::read(&binary_path).unwrap().as_slice() + ); + assert_eq!(b"existing lock\n", fs::read(&lock_path).unwrap().as_slice()); + } else { + assert!(!binary_path.exists(), "rollback left a new binary behind"); + assert!(!lock_path.exists(), "rollback left a new image lock behind"); + } + let moves = fs::read_to_string(fixture.fake_mv_log()).unwrap(); + let destinations = moves.lines().collect::>(); + assert_eq!(2, destinations.len()); + assert!(destinations[0].ends_with(TEST_LOCK_ASSET), "{moves}"); + assert!(destinations[1].ends_with("/registryctl"), "{moves}"); + } +} + +#[cfg(unix)] +#[test] +fn installer_term_during_install_restores_pair_and_exits_143() { + let fixture = InstallerFixture::new(); + fs::create_dir_all(&fixture.install_dir).unwrap(); + fs::write( + fixture.install_dir.join("registryctl"), + b"existing binary\n", + ) + .unwrap(); + fs::write( + fixture.install_dir.join(TEST_LOCK_ASSET), + b"existing lock\n", + ) + .unwrap(); + + let output = fixture.run_with_term_before_binary_replacement(); + + assert_eq!(Some(143), output.status.code()); + assert_eq!( + b"existing binary\n", + fs::read(fixture.install_dir.join("registryctl")) + .unwrap() + .as_slice() + ); + assert_eq!( + b"existing lock\n", + fs::read(fixture.install_dir.join(TEST_LOCK_ASSET)) + .unwrap() + .as_slice() + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("registryctl installed to"), "{stdout}"); + assert!( + !stdout.contains("release image lock installed to"), + "{stdout}" + ); + let moves = fs::read_to_string(fixture.fake_mv_log()).unwrap(); + let destinations = moves.lines().collect::>(); + assert_eq!(2, destinations.len()); + assert!(destinations[0].ends_with(TEST_LOCK_ASSET), "{moves}"); + assert!(destinations[1].ends_with("/registryctl"), "{moves}"); +} + +#[cfg(unix)] +#[test] +fn installer_cleans_staging_without_touching_pair_when_chmod_fails_before_mutation() { + let fixture = InstallerFixture::new(); + fs::create_dir_all(&fixture.install_dir).unwrap(); + fs::write( + fixture.install_dir.join("registryctl"), + b"existing binary\n", + ) + .unwrap(); + fs::write( + fixture.install_dir.join(TEST_LOCK_ASSET), + b"existing lock\n", + ) + .unwrap(); + + let output = fixture.run_with_staging_chmod_failure(); + + assert!(!output.status.success()); + assert_eq!( + b"existing binary\n", + fs::read(fixture.install_dir.join("registryctl")) + .unwrap() + .as_slice() + ); + assert_eq!( + b"existing lock\n", + fs::read(fixture.install_dir.join(TEST_LOCK_ASSET)) + .unwrap() + .as_slice() + ); + let names = fs::read_dir(&fixture.install_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + assert!( + names + .iter() + .all(|name| !name.starts_with(".registryctl-install.")), + "staging directory survived failure: {names:?}" + ); +} + +#[cfg(unix)] +struct InstallerFixture { + _temp: TempDir, + fake_bin: PathBuf, + release_dir: PathBuf, + install_dir: PathBuf, + version: String, +} + +#[cfg(unix)] +impl InstallerFixture { + fn new() -> Self { + Self::for_release(TEST_VERSION, true) + } + + fn for_release(version: &str, include_image_lock: bool) -> Self { + let temp = TempDir::new().unwrap(); + let fake_bin = temp.path().join("fake-bin"); + let release_dir = temp.path().join("release"); + let install_dir = temp.path().join("install"); + fs::create_dir_all(&fake_bin).unwrap(); + fs::create_dir_all(&release_dir).unwrap(); + write_executable( + &fake_bin.join("curl"), + r#"#!/usr/bin/env bash +set -euo pipefail +url="" +dest="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [[ -n "${FAKE_CURL_LOG:-}" ]]; then + printf '%s\n' "$url" >> "$FAKE_CURL_LOG" +fi +cp "${FAKE_RELEASE_DIR}/${url##*/}" "$dest" +"#, + ); + write_executable( + &fake_bin.join("uname"), + r#"#!/usr/bin/env bash +case "${1:-}" in + -s) printf 'Linux\n' ;; + -m) printf 'x86_64\n' ;; + *) exit 1 ;; +esac +"#, + ); + let binary_asset = format!("registryctl-{version}-linux-amd64"); + let lock_asset = format!("registryctl-{version}-image-lock.json"); + fs::write( + release_dir.join(&binary_asset), + b"registryctl release binary\n", + ) + .unwrap(); + let mut checksums = vec![format!( + "{} {}\n", + sha256(&release_dir.join(&binary_asset)), + binary_asset, + )]; + if include_image_lock { + fs::write( + release_dir.join(&lock_asset), + b"registryctl release image lock\n", + ) + .unwrap(); + checksums.push(format!( + "{} {}\n", + sha256(&release_dir.join(&lock_asset)), + lock_asset, + )); + } + fs::write(release_dir.join("SHA256SUMS"), checksums.concat()).unwrap(); + Self { + _temp: temp, + fake_bin, + release_dir, + install_dir, + version: version.to_string(), + } + } + + fn run(&self) -> std::process::Output { + self.command().output().unwrap() + } + + fn run_with_second_mv_failure(&self) -> std::process::Output { + write_executable( + &self.fake_bin.join("mv"), + r#"#!/usr/bin/env bash +set -euo pipefail +count=0 +if [[ -f "$FAKE_MV_COUNT_FILE" ]]; then + read -r count < "$FAKE_MV_COUNT_FILE" +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$FAKE_MV_COUNT_FILE" +printf '%s\n' "${@: -1}" >> "$FAKE_MV_LOG" +if [[ "$count" -eq 2 ]]; then + exit 73 +fi +exec "$REAL_MV" "$@" +"#, + ); + let mut command = self.command(); + command + .env("FAKE_MV_COUNT_FILE", self._temp.path().join("mv-count")) + .env("FAKE_MV_LOG", self.fake_mv_log()) + .env("REAL_MV", "/bin/mv"); + command.output().unwrap() + } + + fn run_with_staging_chmod_failure(&self) -> std::process::Output { + write_executable( + &self.fake_bin.join("chmod"), + "#!/usr/bin/env bash\nexit 74\n", + ); + self.run() + } + + fn run_with_term_before_binary_replacement(&self) -> std::process::Output { + write_executable( + &self.fake_bin.join("mv"), + r#"#!/usr/bin/env bash +set -euo pipefail +count=0 +if [[ -f "$FAKE_MV_COUNT_FILE" ]]; then + read -r count < "$FAKE_MV_COUNT_FILE" +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$FAKE_MV_COUNT_FILE" +printf '%s\n' "${@: -1}" >> "$FAKE_MV_LOG" +if [[ "$count" -eq 2 ]]; then + kill -TERM "$PPID" + exit 0 +fi +exec "$REAL_MV" "$@" +"#, + ); + let mut command = self.command(); + command + .env("FAKE_MV_COUNT_FILE", self._temp.path().join("mv-count")) + .env("FAKE_MV_LOG", self.fake_mv_log()) + .env("REAL_MV", "/bin/mv"); + command.output().unwrap() + } + + fn fake_mv_log(&self) -> PathBuf { + self._temp.path().join("mv-log") + } + + fn fake_curl_log(&self) -> PathBuf { + self._temp.path().join("curl-log") + } + + fn command(&self) -> Command { + let installer = Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh"); + let path = format!( + "{}:{}", + self.fake_bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut command = Command::new("bash"); + command + .arg(installer) + .env("PATH", path) + .env("FAKE_RELEASE_DIR", &self.release_dir) + .env("FAKE_CURL_LOG", self.fake_curl_log()) + .env("REGISTRYCTL_VERSION", &self.version) + .env("REGISTRYCTL_INSTALL_DIR", &self.install_dir); + command + } +} + +#[cfg(unix)] +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +fn sha256(path: &Path) -> String { + for (program, args) in [("shasum", vec!["-a", "256"]), ("sha256sum", vec![])] { + if let Ok(output) = Command::new(program).args(args).arg(path).output() { + if output.status.success() { + return String::from_utf8(output.stdout) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string(); + } + } + } + panic!("test needs shasum or sha256sum"); +} diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index 0606bc9d..48a8be03 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -4,9 +4,9 @@ # This is a source-under-test CI gate: it builds registryctl and the release # product image shapes from the current checkout. It deliberately does not run # the published installer or release assets; the fresh-reader release proof is -# tracked separately in GH#198. The generated Compose files are rebound to the -# local images after each registryctl generation command so GH#278 cannot turn -# this gate into either a false failure or a false green. +# tracked separately in GH#198. The gate writes a valid source-under-test image +# lock beside registryctl, then rebinds generated Compose files to the local +# images after each generation command. set -euo pipefail @@ -33,6 +33,7 @@ CURRENT_SECRET_FILE="" COMMAND_COUNTER=0 REGISTRYCTL_BIN="" LAST_OUTPUT="" +SOURCE_IMAGE_LOCK="" cleanup_stack() { local project_dir="$1" @@ -50,6 +51,9 @@ cleanup() { cleanup_stack "${PROJECT_DIRS[$index]}" "${PROJECT_NAMES[$index]}" done docker image rm -f "$RELAY_IMAGE" "$NOTARY_IMAGE" >/dev/null 2>&1 || true + if [[ -n "$SOURCE_IMAGE_LOCK" ]]; then + rm -f "$SOURCE_IMAGE_LOCK" + fi rm -rf "$WORK_ROOT" if ((exit_code == 0)); then printf 'registryctl tutorial source check: PASS\n' @@ -129,6 +133,27 @@ build_source_under_test() { printf 'registryctl source binary is not executable: %s\n' "$REGISTRYCTL_BIN" >&2 exit 1 } + + local registryctl_version source_ref + registryctl_version="$($REGISTRYCTL_BIN --version | awk '{print $2}')" + if [[ ! "$registryctl_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'unexpected registryctl source version: %s\n' "$registryctl_version" >&2 + exit 1 + fi + source_ref="$(git -C "$REPO_ROOT" rev-parse HEAD)" + SOURCE_IMAGE_LOCK="$(dirname "$REGISTRYCTL_BIN")/registryctl-v${registryctl_version}-image-lock.json" + printf '%s\n' \ + '{' \ + ' "schema_version": "registryctl.release_image_lock.v1",' \ + " \"release_tag\": \"v${registryctl_version}\"," \ + " \"manifest_source_ref\": \"${source_ref}\"," \ + " \"tag_target\": \"${source_ref}\"," \ + ' "platform": "linux/amd64",' \ + ' "images": {' \ + ' "registry-relay": "ghcr.io/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",' \ + ' "registry-notary": "ghcr.io/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' \ + ' }' \ + '}' >"$SOURCE_IMAGE_LOCK" } sanitize_output() { diff --git a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx index bcac3ed0..71a69488 100644 --- a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx +++ b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx @@ -52,6 +52,15 @@ sequence. The [revert section](#roll-back-to-the-previous-release) covers this. 2. Download and verify the target release artifacts (cosign signature and, where present, SLSA provenance) before anything else touches them. + When upgrading `registryctl` to `v0.9.0` or later, keep its versioned + `registryctl-vX.Y.Z-image-lock.json` beside the binary and verify both against the + release `SHA256SUMS` and available authenticity evidence. Existing generated projects + can keep starting from the immutable image references already stored in their project + files. Any later `registryctl init` or `registryctl add` requires the matching new lock + before it changes those files. Do not reuse a lock from another CLI version or replace + its digest references with mutable tags. For an intentionally separate verified lock + location, set `REGISTRYCTL_IMAGE_LOCK` to that exact file. + 3. Validate your current config against the new binary offline, before deploying it: ```sh diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index b98cb9b9..9acd6742 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-10" doc_type: reference locale: en standards_referenced: [] @@ -18,6 +18,13 @@ This page lists the commands, subcommands, and flags released in registryctl `v0 ## Project setup `init` creates a local project. `add` adds a product to the current project. +Beginning with registryctl `v0.9.0`, both generation commands require the strict +`registryctl-vX.Y.Z-image-lock.json` from the same release beside the running +binary. Set `REGISTRYCTL_IMAGE_LOCK` only when an operator has verified the same +file at a separate explicit path. Registryctl does not search the current +working directory or query a registry for image tags. Lifecycle commands use +the immutable image references already stored in an existing project and do +not need the lock. Registryctl `v0.8.4` predates the image-lock contract. | Subcommand or flag | Purpose | | --- | --- | diff --git a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx index 0c7d1790..356a5de1 100644 --- a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx @@ -35,9 +35,11 @@ Do not use the generated local keys in production. Install `registryctl` without cloning the repository: -The quick installer verifies the downloaded binary against the release's -`SHA256SUMS`; it does not authenticate the installer script or release. Before -running it, review the available signature and provenance evidence in the +The quick installer verifies downloaded release assets against the release's +`SHA256SUMS`. The pinned `v0.8.4` installer installs the binary; beginning with +`v0.9.0`, the matching installer installs the binary and release image lock. It +does not authenticate the installer script or release. Before running it, +review the available signature and provenance evidence in the canonical [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/main/release/VERIFY.md) guide. Evidence availability varies by release, and `v0.8.0` is unsigned. @@ -52,7 +54,9 @@ If your shell cannot find `registryctl`, add the install directory printed by th Prebuilt `registryctl` assets exist for Linux x86_64, Linux arm64, and macOS arm64. On Intel macOS, the installer stops after printing the pinned `cargo install` command. It does not run the source build automatically. -After installing a Rust toolchain, run the command printed by the installer. +After installing a Rust toolchain, run the command printed by the installer. For +`v0.9.0` or later, also checksum-verify the matching release image lock and +place it beside the Cargo-installed binary, as described in the troubleshooting table. ## Create the sample project @@ -350,11 +354,12 @@ Relay refused to start when the floor dropped under 2 on personal data. | Symptom | Cause | Resolution | | --- | --- | --- | | `registryctl` is not found | The install directory is not on `PATH`. | Add the directory printed by the installer, usually `~/.local/bin`, to `PATH`. | -| The installer reports an unsupported platform | No binary is published for that OS or CPU. | Install Rust with [`rustup`](https://rustup.rs), then run `cargo install --git https://github.com/registrystack/registry-stack --tag v0.8.4 registryctl --locked`. | +| The installer reports an unsupported platform | No binary is published for that OS or CPU. | Run the pinned `cargo install` command printed by the installer. For `v0.9.0` or later, also checksum-verify the matching `registryctl-vX.Y.Z-image-lock.json` release asset and place it beside the installed binary. | | `registryctl start` cannot find Docker | Docker or another Compose provider is not installed or running. | Start Docker Desktop, OrbStack, Colima, Podman, or your supported provider, then run `registryctl start` again. | | Docker reports no `linux/arm64` manifest for a Registry Stack image | The v0.8.4 Relay and Notary images are published for `linux/amd64` only. | Start with `DOCKER_DEFAULT_PLATFORM=linux/amd64 registryctl start`. | | `registryctl start` fails and the container log shows `failed to parse config YAML ... unknown field` | The locally cached container image does not match the digest-pinned image in the generated `compose.yaml`. | Run `docker compose pull` in the project directory (on Apple silicon, prefix it with `DOCKER_DEFAULT_PLATFORM=linux/amd64`), then `registryctl start` again. | -| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | No local workaround reconstructs the value; track [GH#278](https://github.com/registrystack/registry-stack/issues/278) for the re-pinned image, or pin the project's `compose.yaml` images to the matching release tag: replace `ghcr.io/registrystack/registry-relay@sha256:` with `ghcr.io/registrystack/registry-relay:v0.8.4` (same pattern for `registry-notary`), then run `docker compose pull`. | +| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Pin the project's `compose.yaml` to the published v0.8.4 release digests: `ghcr.io/registrystack/registry-relay@sha256:93e194500a3500ba3f6331d5a0a9a3069127c48a709beccb083a5fcbdbc3ec61` and, when present, `ghcr.io/registrystack/registry-notary@sha256:0cf05184885d7ed17dd9889e20f3797eb2a9ad07517f4ac7c05c03b774a00b8f`. Then run `docker compose pull`; reapply the pins after a generation command rewrites `compose.yaml`. | +| With registryctl `v0.9.0` or later, `registryctl init` reports that its image lock is missing or invalid | The binary was moved or built without the strict image lock from the same release, or the file failed its release, source, platform, or digest checks. Registryctl `v0.8.4` does not use this lock. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. For an operator-managed or source-test location, set `REGISTRYCTL_IMAGE_LOCK` to the checksum-verified lock from that exact release. Do not substitute a lock from another version or a mutable image tag. | | `registryctl start` fails with `Relay did not become healthy and ready before timeout` after a config edit | The edited `relay/config.yaml` fails validation, so the container exits at startup. | Run `registryctl logs`, fix the field named in the `ERROR` line, then run `registryctl stop` and `registryctl start` again. | | A row read returns `403 Forbidden` | The key is valid but lacks the row-read scope. | Use `ROW_READER_RAW` for row reads. | | A row read returns `400 auth.purpose_required` | The entity requires a `Data-Purpose` header. | Send `Data-Purpose: https://example.local/purpose/tutorial` or another purpose URI. | diff --git a/docs/site/src/content/docs/tutorials/run-notary-standalone-for-api.mdx b/docs/site/src/content/docs/tutorials/run-notary-standalone-for-api.mdx index 74c1eb7f..902a8ae3 100644 --- a/docs/site/src/content/docs/tutorials/run-notary-standalone-for-api.mdx +++ b/docs/site/src/content/docs/tutorials/run-notary-standalone-for-api.mdx @@ -222,4 +222,5 @@ stayed private, and only the claim's disclosed predicate crossed the API boundar | `registryctl start` fails with `network ... not found` | The source Compose network name is wrong or the source project is not running. | Start the source project and pass the correct `_default` network name. | | Claim evaluation returns a source auth error | Notary cannot authenticate to the source. | Confirm the source token in `secrets/local.env` and restart Notary. | | Claim evaluation returns `409 Evidence not available` | The target id is not available from the source, or the dataset, entity, or lookup field does not match the source contract. | Use a known target id or inspect the Registry Data API source contract. | -| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | No local workaround reconstructs the value; track [GH#278](https://github.com/registrystack/registry-stack/issues/278) for the re-pinned image, or pin the project's `compose.yaml` images to the matching release tag: replace `ghcr.io/registrystack/registry-notary@sha256:` with `ghcr.io/registrystack/registry-notary:v0.8.4` (same pattern for `registry-relay` where a project includes it), then run `docker compose pull`. | +| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Pin `compose.yaml` to the published v0.8.4 Notary digest `ghcr.io/registrystack/registry-notary@sha256:0cf05184885d7ed17dd9889e20f3797eb2a9ad07517f4ac7c05c03b774a00b8f` and, when the project includes Relay, `ghcr.io/registrystack/registry-relay@sha256:93e194500a3500ba3f6331d5a0a9a3069127c48a709beccb083a5fcbdbc3ec61`. Then run `docker compose pull`; reapply the pins after a generation command rewrites `compose.yaml`. | +| With registryctl `v0.9.0` or later, `registryctl init notary` reports that its image lock is missing or invalid | The binary is not paired with the strict image lock from the same release, or the lock failed its release and digest checks. Registryctl `v0.8.4` does not use this lock. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. If the lock is intentionally stored elsewhere, set `REGISTRYCTL_IMAGE_LOCK` to that exact verified file. Do not replace the digest pin with a mutable tag. | diff --git a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx index c9d2919b..b552673e 100644 --- a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx @@ -384,5 +384,6 @@ It does not delete your workbook, generated configs, local keys, or smoke result | Claim evaluation returns a source auth error | Notary cannot authenticate to Relay with `EVIDENCE_SOURCE_REGISTRY_RELAY_TOKEN`. | Confirm `secrets/local.env` has the source token and Relay is running. | | Claim evaluation returns `409 Evidence not available` | The target id is not in the sample workbook or the Relay entity lookup changed. | Use `per-2001` for the tutorial target, or inspect the Relay `person` entity. | | `registryctl doctor` reports `not_run` and asks you to install `registry-relay` | `doctor` validates configs with native product binaries, which this Docker-based tutorial does not install. | Use `registryctl notary smoke` and `registryctl logs` to check a config edit, or install the `registry-relay` and `registry-notary` binaries. | -| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | No local workaround reconstructs the value; track [GH#278](https://github.com/registrystack/registry-stack/issues/278) for the re-pinned image, or pin the project's `compose.yaml` images to the matching release tag: replace `ghcr.io/registrystack/registry-relay@sha256:` with `ghcr.io/registrystack/registry-relay:v0.8.4` (same pattern for `registry-notary`), then run `docker compose pull`. `registryctl add notary` regenerates `compose.yaml`, so reapply the pin after it runs. | +| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Pin `compose.yaml` to the published v0.8.4 digests `ghcr.io/registrystack/registry-relay@sha256:93e194500a3500ba3f6331d5a0a9a3069127c48a709beccb083a5fcbdbc3ec61` and `ghcr.io/registrystack/registry-notary@sha256:0cf05184885d7ed17dd9889e20f3797eb2a9ad07517f4ac7c05c03b774a00b8f`, then run `docker compose pull`. `registryctl add notary` regenerates `compose.yaml`, so reapply the pins after it runs. | +| With registryctl `v0.9.0` or later, `registryctl add notary` reports that its image lock is missing or invalid | The binary and strict release image lock are no longer installed together, or the lock does not match the CLI release and tag-built image evidence. Registryctl `v0.8.4` does not use this lock. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. For an operator-managed or source-test location, set `REGISTRYCTL_IMAGE_LOCK` to the checksum-verified lock from that exact release. Existing projects can still start from their stored pins, but generation commands fail closed. | | `registryctl start` replaces containers you did not expect | Docker Compose derives the project name from the directory basename only, so two differently located `my-first-api` directories share one Compose project. | Give each project directory a unique name, or stop the other project first. | diff --git a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx index b00ca0dd..eb89b499 100644 --- a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx +++ b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx @@ -255,7 +255,8 @@ For the mental model behind these blocks, see the Registry Notary | --- | --- | --- | | `registryctl notary smoke` or the explicit evaluation returns a source auth error | Registry Notary could not authenticate to OpenCRVS with the configured OAuth client credentials. | Confirm `DCI_CLIENT_ID` and `DCI_CLIENT_SECRET` in `secrets/local.env` match a valid OpenCRVS OAuth client for the target environment. | | The smoke or evaluation returns `source.unavailable` (HTTP 503) and the logs show a DNS lookup failure | The `--source-url` host does not resolve; the placeholder was left in, or the URL is wrong. | Rerun `registryctl init notary` with your real OpenCRVS base URL, or fix `source.url` in `notary/config.yaml`. | -| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Replace `ghcr.io/registrystack/registry-notary@sha256:` with `ghcr.io/registrystack/registry-notary:v0.8.4` in `compose.yaml`, run `docker compose pull`, and start again; reapply after any command that regenerates `compose.yaml`. | +| `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Replace the Notary image in `compose.yaml` with the published v0.8.4 digest `ghcr.io/registrystack/registry-notary@sha256:0cf05184885d7ed17dd9889e20f3797eb2a9ad07517f4ac7c05c03b774a00b8f`, run `docker compose pull`, and start again; reapply after any command that regenerates `compose.yaml`. | +| With registryctl `v0.9.0` or later, `registryctl init notary` reports that its image lock is missing or invalid | The binary was moved or built without the strict image lock from the same release, or the lock does not match the CLI release and tag-built digest evidence. Registryctl `v0.8.4` does not use this lock. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. For an intentionally separate location, set `REGISTRYCTL_IMAGE_LOCK` to the exact verified lock. Never replace the digest reference with a mutable image tag. | | Docker reports no `linux/arm64` manifest for a v0.8.4 image | v0.8.4 images publish for `linux/amd64` only. | Start with `DOCKER_DEFAULT_PLATFORM=linux/amd64 registryctl start`, as shown in [Validate and start](#validate-and-start). | | `registryctl init notary` fails with `--source-token-from-env is not used with --source-kind opencrvs_dci` | `--source-token-from-env` was passed alongside `--source-kind opencrvs-dci`. | Configure `DCI_CLIENT_ID` and `DCI_CLIENT_SECRET` directly in `secrets/local.env` instead. | diff --git a/release/VERIFY.md b/release/VERIFY.md index db414123..ef368ef5 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -1,32 +1,40 @@ # Verify A Registry Stack Release -The release workflow's `github-release` job signs a selected set of artifacts -using keyless cosign. For each artifact that job signs, it uploads the artifact -with a sibling `.sig` signature and `.pem` signing certificate. For -tag-triggered releases, a separate SLSA generator job uploads the release-level -provenance asset named +The release workflow's `github-release` job signs release artifacts using +keyless cosign. For each signed artifact, it uploads the artifact with a sibling +`.sig` signature and `.pem` signing certificate. For tag-triggered releases, a +separate SLSA generator job uploads the release-level provenance asset named `registry-stack-${tag}-release-provenance.intoto.jsonl`. The provenance asset -does not have sibling cosign files; `slsa-verifier` authenticates it and checks +does not have sibling cosign files. `slsa-verifier` authenticates it and checks that the release artifact is one of its subjects. Earlier releases, including `v0.8.2`, may include cosign signatures but no SLSA -provenance asset. `v0.8.0` is unsigned. The commands below verify a -tag-triggered release that includes provenance. Replace `v0.8.4` and the asset -name with the release you are checking. +provenance asset. `v0.8.0` is unsigned. The first procedure below truthfully +describes `v0.8.4`, which ships registryctl as a binary without a release image +lock. The second procedure describes the additional registryctl image-lock +assets introduced in `v0.9.0`. + +The current lock-bearing release workflow refuses to build or push a release +below `v0.9.0`. A historical tag rerun uses the workflow committed at that tag. Repeatable build evidence for the `v0.8.3` Linux amd64 binary assets is documented in [`release/REPEATABLE-BUILDS.md`](REPEATABLE-BUILDS.md). ## Install Verification Tools -The commands below use the GitHub CLI, cosign, and `slsa-verifier`. +The commands below require the GitHub CLI, cosign, `slsa-verifier`, `jq`, and +GNU `sha256sum`. On macOS, install GNU coreutils and place its `gnubin` +directory on `PATH` so the command is available as `sha256sum`. + Install `slsa-verifier` from the upstream [`slsa-framework/slsa-verifier` releases](https://github.com/slsa-framework/slsa-verifier/releases). For repeatable evidence, pin and record the verifier version you used. As of 2026-07-09, the current upstream release is [`v2.7.1`](https://github.com/slsa-framework/slsa-verifier/releases/tag/v2.7.1). -## Download Assets +## Verify A v0.8.4 Registryctl Binary + +### Download The v0.8.4 Assets ```bash tag=v0.8.4 @@ -45,15 +53,16 @@ gh release download "${tag}" \ --pattern "${provenance}" ``` -## Check The Asset Hash +### Check The v0.8.4 Binary Hash ```bash sha256sum --check --ignore-missing SHA256SUMS ``` -`sha256sum` should report `OK` for the downloaded asset. +`sha256sum` should report `OK` for the downloaded binary. Registryctl `v0.8.4` +does not have an image-lock asset. -## Verify The Keyless Signature +### Verify The v0.8.4 Keyless Signature ```bash cosign verify-blob "${asset}" \ @@ -63,10 +72,7 @@ cosign verify-blob "${asset}" \ --certificate-identity "https://github.com/registrystack/registry-stack/.github/workflows/release.yml@refs/tags/${tag}" ``` -For release-capsule or image-evidence assets, use the same command with that -asset's filename and matching `.sig` and `.pem` files. - -## Verify The SLSA Provenance +### Verify The v0.8.4 SLSA Provenance ```bash slsa-verifier verify-artifact "${asset}" \ @@ -75,26 +81,188 @@ slsa-verifier verify-artifact "${asset}" \ --source-tag "${tag}" ``` +## Verify v0.9.0+ Registryctl Image-Lock Assets + +Use the matching procedure for a `v0.9.0` or later tag. Start from a fresh +working directory rather than the `verify-v0.8.4` directory above. + +### Download The v0.9.0+ Assets + +```bash +tag=v0.9.0 +asset=registryctl-${tag}-linux-amd64 +image_lock=registryctl-${tag}-image-lock.json +image_lock_sbom=${image_lock}.spdx.json +capsule=registry-stack-${tag}-release-capsule.json +provenance=registry-stack-${tag}-release-provenance.intoto.jsonl + +mkdir -p "verify-${tag}" +cd "verify-${tag}" + +gh release download "${tag}" \ + --repo registrystack/registry-stack \ + --pattern "${asset}" \ + --pattern "${asset}.sig" \ + --pattern "${asset}.pem" \ + --pattern "${image_lock}" \ + --pattern "${image_lock}.sig" \ + --pattern "${image_lock}.pem" \ + --pattern "${image_lock_sbom}" \ + --pattern "${image_lock_sbom}.sig" \ + --pattern "${image_lock_sbom}.pem" \ + --pattern "${capsule}" \ + --pattern "${capsule}.sig" \ + --pattern "${capsule}.pem" \ + --pattern "registry-relay.digest" \ + --pattern "registry-relay.digest.sig" \ + --pattern "registry-relay.digest.pem" \ + --pattern "registry-notary.digest" \ + --pattern "registry-notary.digest.sig" \ + --pattern "registry-notary.digest.pem" \ + --pattern "SHA256SUMS" \ + --pattern "${provenance}" +``` + +### Check The Binary And Image-Lock Hashes + +```bash +sha256sum --check --ignore-missing SHA256SUMS +chmod 0755 "${asset}" +test "$("./${asset}" --version)" = "registryctl ${tag#v}" +``` + +`sha256sum` should report `OK` for the binary and image lock. The version check +proves that the binary searches for the same versioned lock shipped beside it. + +### Verify The Strict Image-Lock Bindings + +The lock is a release file, not an executable. Confirm its strict shape, release +identity, source lineage, platform, and literal digest-bound repositories: + +```bash +jq -e --arg tag "${tag}" ' + keys == ["images", "manifest_source_ref", "platform", "release_tag", "schema_version", "tag_target"] and + .schema_version == "registryctl.release_image_lock.v1" and + .release_tag == $tag and + .platform == "linux/amd64" and + (.manifest_source_ref | test("^[0-9a-f]{40}$")) and + (.tag_target | test("^[0-9a-f]{40}$")) and + (.images | keys == ["registry-notary", "registry-relay"]) and + (.images["registry-relay"] | test("^ghcr\\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}$")) and + (.images["registry-notary"] | test("^ghcr\\.io/registrystack/registry-notary@sha256:[0-9a-f]{64}$")) +' "${image_lock}" + +test "$(jq -r .manifest_source_ref "${image_lock}")" = \ + "$(jq -r .source.source_ref "${capsule}")" +test "$(jq -r .tag_target "${image_lock}")" = \ + "$(jq -r .source.source_commit "${capsule}")" +cmp <(jq -r '.images["registry-relay"]' "${image_lock}") registry-relay.digest +cmp <(jq -r '.images["registry-notary"]' "${image_lock}") registry-notary.digest +``` + +### Verify The File SBOM And Capsule Classification + +Confirm the SPDX file subject describes this JSON file and its actual hash, then +confirm the capsule classifies it under `release_files`, not `binaries`: + +```bash +image_lock_sha=$(sha256sum "${image_lock}" | awk '{print $1}') +image_lock_sbom_sha=$(sha256sum "${image_lock_sbom}" | awk '{print $1}') + +jq -e --arg name "${image_lock}" --arg sha "${image_lock_sha}" ' + .documentDescribes as $described | + any(.packages[]; + (.SPDXID as $id | ($described | index($id)) != null) and + .name == $name and + .packageFileName == $name and + any(.checksums[]; .algorithm == "SHA256" and .checksumValue == $sha) + ) +' "${image_lock_sbom}" + +jq -e \ + --arg name "${image_lock}" \ + --arg sha "${image_lock_sha}" \ + --arg sbom "${image_lock_sbom}" \ + --arg sbom_sha "${image_lock_sbom_sha}" ' + any(.release_files[]; + .name == $name and + .kind == "registryctl-release-image-lock" and + .sha256 == $sha and + .sbom.asset_name == $sbom and + .sbom.sha256 == $sbom_sha + ) and + (all(.binaries[]; .name != $name)) +' "${capsule}" +``` + +### Verify Signatures And Provenance For Every Lock Subject + +```bash +for signed_asset in \ + "${asset}" \ + "${image_lock}" \ + "${image_lock_sbom}" \ + "${capsule}" \ + registry-relay.digest \ + registry-notary.digest +do + cosign verify-blob "${signed_asset}" \ + --signature "${signed_asset}.sig" \ + --certificate "${signed_asset}.pem" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "https://github.com/registrystack/registry-stack/.github/workflows/release.yml@refs/tags/${tag}" + + slsa-verifier verify-artifact "${signed_asset}" \ + --provenance-path "${provenance}" \ + --source-uri github.com/registrystack/registry-stack \ + --source-tag "${tag}" +done +``` + The provenance subject set covers release artifacts before their generated -`.sig` and `.pem` files are added. +`.sig` and `.pem` files are added. The lock, file-subject SBOM, capsule, and +exact Relay and Notary digest evidence are all independent signed and +provenance-covered subjects. + +## Install Or Move Registryctl + +For `v0.9.0` and later, the matching release installer checksum-verifies and +installs the binary and versioned image lock beside each other. Fetch the +installer from the same pinned tag whose assets it installs: + +```bash +curl -fsSL "https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/${tag}/crates/registryctl/install.sh" | + REGISTRYCTL_VERSION="${tag}" bash +``` + +A standalone or Cargo-installed registryctl does not acquire the `v0.9.0+` +lock automatically. After verifying it as above, either copy +`registryctl-${tag}-image-lock.json` beside the running binary or set +`REGISTRYCTL_IMAGE_LOCK` to its exact path. Registryctl never searches the +current working directory. Existing generated projects can start from their +stored digest pins without the lock, but `registryctl init` and `registryctl add` +fail before mutation when the matching lock is absent or invalid. + +Registryctl `v0.8.4` predates this lock contract and remains a binary-only +installation. + +## Manual Rebuilds And Current Scope If a release was rebuilt manually through `workflow_dispatch`, inspect the certificate identity, the release capsule's workflow URL, and the SLSA provenance source before accepting the asset. The release workflow only uploads SLSA provenance when the run is associated with `refs/tags/${tag}`. -## Current Scope - -The `github-release` job signs binaries, checksums, release binary SBOMs, -image-input binary SBOMs, image evidence files, image SBOMs, Grype reports, and -release capsules before uploading them. When the workflow runs from the release -tag ref, the separate SLSA generator job publishes provenance for those -non-signature artifacts. OCI image signatures are not yet published for the -root monorepo release. +For `v0.9.0` and later, the `github-release` job signs binaries, the registryctl +image lock, checksums, release file SBOMs, image-input binary SBOMs, image +evidence files, image SBOMs, Grype reports, and release capsules before upload. +When the workflow runs from the release tag ref, the separate SLSA generator +job publishes provenance for those non-signature artifacts. OCI image +signatures are not yet published for the root monorepo release. -The release capsule summarizes binary asset hashes, binary SBOM asset names, -image digests, image SBOMs, Grype reports, workflow lineage, and release -warnings. It does not carry per-artifact signature or provenance status fields; -verify cosign signatures for artifacts uploaded by the `github-release` job -from their sibling `.sig` and `.pem` files. Verify the separately uploaded +The release capsule summarizes binary asset hashes, the image lock under +`release_files`, file SBOM asset names, image digests, image SBOMs, Grype +reports, workflow lineage, and release warnings. It does not carry +per-artifact signature or provenance status fields. Verify cosign signatures +from sibling `.sig` and `.pem` files, and verify the separately uploaded release-level provenance asset with `slsa-verifier`. diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 9fbd9768..ace30804 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -20,6 +20,34 @@ HEX40 = re.compile(r"^[0-9a-f]{40}$") SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") SEMVER_TAG = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+$") IMAGE_DIGEST_REF = re.compile(r"^([^@]+)@(sha256:[0-9a-f]{64})$") +RELEASE_IMAGE_LOCK_SCHEMA = "registryctl.release_image_lock.v1" +RELEASE_IMAGE_LOCK_PLATFORM = "linux/amd64" +RELEASE_IMAGE_LOCK_MINIMUM_VERSION = (0, 9, 0) +RELEASE_IMAGE_LOCK_REPOSITORIES = { + "registry-relay": "ghcr.io/registrystack/registry-relay", + "registry-notary": "ghcr.io/registrystack/registry-notary", +} + + +def require_registryctl_image_lock_release_version(version: str) -> None: + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is None: + raise ValueError( + "registryctl image lock release version must be semantic version text, " + f"got {version!r}" + ) + parsed = tuple(int(part) for part in version.split(".")) + if parsed < RELEASE_IMAGE_LOCK_MINIMUM_VERSION: + raise ValueError("registryctl image lock releases require version 0.9.0 or later") + + +def verify_registryctl_image_lock_release_version(version: str) -> int: + try: + require_registryctl_image_lock_release_version(version) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"verified registryctl image lock release version {version}") + return 0 def load_yaml(path: Path) -> object: @@ -36,6 +64,157 @@ def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def read_release_image_digest(path: Path, image_name: str) -> str: + if path.is_symlink() or not path.is_file(): + raise ValueError(f"{image_name} digest evidence must be a regular file: {path}") + if path.stat().st_size > 1024: + raise ValueError(f"{image_name} digest evidence exceeds 1024 bytes: {path}") + body = path.read_text(encoding="utf-8") + lines = body.splitlines() + if len(lines) != 1 or body not in {lines[0], f"{lines[0]}\n"}: + raise ValueError(f"{image_name} digest evidence must contain exactly one unpadded line") + digest_ref = lines[0] + match = IMAGE_DIGEST_REF.fullmatch(digest_ref) + if match is None: + raise ValueError(f"{image_name} digest ref must match image@sha256:<64 lowercase hex>") + expected_repository = RELEASE_IMAGE_LOCK_REPOSITORIES[image_name] + if match.group(1) != expected_repository: + raise ValueError( + f"{image_name} digest ref repository must be {expected_repository}, got {match.group(1)}" + ) + return digest_ref + + +def render_registryctl_image_lock( + manifest_path: Path, + relay_digest_path: Path, + notary_digest_path: Path, + tag_target: str, + output: Path, +) -> int: + try: + manifest = load_yaml(manifest_path) + if not isinstance(manifest, dict) or not isinstance(manifest.get("stack"), dict): + raise ValueError("release manifest stack must be an object") + stack = manifest["stack"] + version = str(stack.get("version", "")) + require_registryctl_image_lock_release_version(version) + release_tag = str(stack.get("source_tag", "")) + if release_tag != f"v{version}": + raise ValueError(f"manifest stack.source_tag must be v{version}") + manifest_source_ref = str(stack.get("source_ref", "")) + if HEX40.fullmatch(manifest_source_ref) is None: + raise ValueError("manifest stack.source_ref must be 40 lowercase hexadecimal characters") + if HEX40.fullmatch(tag_target) is None: + raise ValueError("tag target must be 40 lowercase hexadecimal characters") + expected_name = f"registryctl-v{version}-image-lock.json" + if output.name != expected_name: + raise ValueError(f"image lock output filename must be {expected_name}") + if output.is_symlink() or (output.exists() and not output.is_file()): + raise ValueError(f"image lock output must be a regular file path: {output}") + + relay_digest = read_release_image_digest(relay_digest_path, "registry-relay") + notary_digest = read_release_image_digest(notary_digest_path, "registry-notary") + image_lock = { + "schema_version": RELEASE_IMAGE_LOCK_SCHEMA, + "release_tag": release_tag, + "manifest_source_ref": manifest_source_ref, + "tag_target": tag_target, + "platform": RELEASE_IMAGE_LOCK_PLATFORM, + "images": { + "registry-relay": relay_digest, + "registry-notary": notary_digest, + }, + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(image_lock, indent=2, sort_keys=True) + "\n", encoding="utf-8") + except (KeyError, OSError, UnicodeError, ValueError, yaml.YAMLError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(f"rendered registryctl release image lock {output}") + return 0 + + +def validate_registryctl_image_lock_document( + data: object, + *, + version: str, + release_tag: str, + manifest_source_ref: str, + tag_target: str, +) -> None: + if not isinstance(data, dict): + raise ValueError("registryctl release image lock must be a JSON object") + expected_keys = { + "schema_version", + "release_tag", + "manifest_source_ref", + "tag_target", + "platform", + "images", + } + if set(data) != expected_keys: + raise ValueError("registryctl release image lock has missing or unknown top-level fields") + expected_values = { + "schema_version": RELEASE_IMAGE_LOCK_SCHEMA, + "release_tag": release_tag, + "manifest_source_ref": manifest_source_ref, + "tag_target": tag_target, + "platform": RELEASE_IMAGE_LOCK_PLATFORM, + } + for field, expected in expected_values.items(): + if data.get(field) != expected: + raise ValueError(f"registryctl release image lock {field} must be {expected!r}") + images = data.get("images") + if not isinstance(images, dict) or set(images) != set(RELEASE_IMAGE_LOCK_REPOSITORIES): + raise ValueError("registryctl release image lock images has missing or unknown fields") + for image_name, repository in RELEASE_IMAGE_LOCK_REPOSITORIES.items(): + digest_ref = images.get(image_name) + match = IMAGE_DIGEST_REF.fullmatch(digest_ref) if isinstance(digest_ref, str) else None + if match is None or match.group(1) != repository: + raise ValueError( + f"registryctl release image lock {image_name} must be {repository}@sha256:<64 lowercase hex>" + ) + if data["release_tag"] != f"v{version}": + raise ValueError(f"registryctl release image lock release_tag must match v{version}") + + +def verify_registryctl_binary_version(binary: Path, version: str) -> int: + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is None: + print(f"error: expected registryctl version must be semantic version text, got {version!r}", file=sys.stderr) + return 1 + if binary.is_symlink() or not binary.is_file(): + print(f"error: registryctl binary must be a regular file: {binary}", file=sys.stderr) + return 1 + try: + result = subprocess.run( + [str(binary.resolve()), "--version"], + text=True, + capture_output=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f"error: failed to execute registryctl binary {binary}: {exc}", file=sys.stderr) + return 1 + expected = f"registryctl {version}" + if result.returncode != 0: + print( + f"error: registryctl binary --version exited {result.returncode}: {result.stderr.strip()}", + file=sys.stderr, + ) + return 1 + if result.stderr or result.stdout != f"{expected}\n": + print( + f"error: registryctl binary version must be exactly {expected!r}, got {result.stdout.strip()!r}", + file=sys.stderr, + ) + return 1 + print(f"verified registryctl binary version {version}: {binary}") + return 0 + + def copy_required_asset(asset_dir: Path, name: str, output_dir: Path) -> None: source = asset_dir / name if not source.is_file(): @@ -75,6 +254,7 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i f"registryctl-{tag}-macos-arm64", f"registryctl-{tag}-linux-arm64", ] + optional_release_file_names = [f"registryctl-{tag}-image-lock.json"] image_names = [ "registry-notary", "registry-notary-source-adapter-sidecar", @@ -82,12 +262,16 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i ] staged_optional: list[str] = [] + staged_release_files: list[str] = [] try: for name in binary_names: copy_required_asset(asset_dir, name, binary_dir) for name in optional_binary_names: if copy_optional_asset(asset_dir, name, binary_dir): staged_optional.append(name) + for name in optional_release_file_names: + if copy_optional_asset(asset_dir, name, binary_dir): + staged_release_files.append(name) for image in image_names: copy_required_asset(asset_dir, f"{image}.digest", image_evidence_dir) except OSError as exc: @@ -97,10 +281,16 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i print( f"staged {len(binary_names)} binary assets " f"({len(staged_optional)}/{len(optional_binary_names)} optional cross-platform) " + f"and {len(staged_release_files)}/{len(optional_release_file_names)} optional release files " f"and {len(image_names)} image digest assets for {tag}" ) for name in optional_binary_names: print(f" optional binary {name}: {'staged' if name in staged_optional else 'absent (skipped)'}") + for name in optional_release_file_names: + print( + f" optional release file {name}: " + f"{'staged' if name in staged_release_files else 'absent (skipped)'}" + ) return 0 @@ -486,6 +676,7 @@ def render_capsule( default_branch_protection: str, repo: Path, default_branch: str, + require_registryctl_image_lock: bool = False, ) -> int: manifest = load_yaml(manifest_path) if not isinstance(manifest, dict): @@ -498,20 +689,26 @@ def render_capsule( try: checksums = parse_sha256s(binary_dir / "SHA256SUMS") binaries = [] + release_files = [] + expected_image_lock_name = f"registryctl-v{version}-image-lock.json" for path in sorted(binary_dir.iterdir()) if binary_dir.exists() else []: if not path.is_file() or path.name == "SHA256SUMS": continue - binary_sha256 = verified_binary_sha256(path, checksums) + if path.name.endswith("-image-lock.json") and path.name != expected_image_lock_name: + raise ValueError( + f"unexpected registryctl image lock filename {path.name}; expected {expected_image_lock_name}" + ) + file_sha256 = verified_binary_sha256(path, checksums) sbom = binary_sbom_dir / f"{path.name}.spdx.json" if not sbom.is_file(): - raise ValueError(f"{path.name} is missing a binary SBOM file") + raise ValueError(f"{path.name} is missing a file SBOM") sbom_report = load_json(sbom) - if not spdx_subject_contains_file_digest(sbom_report, path.name, binary_sha256): - raise ValueError(f"{path.name} SBOM subject does not contain sha256:{binary_sha256}") - binary = { + if not spdx_subject_contains_file_digest(sbom_report, path.name, file_sha256): + raise ValueError(f"{path.name} SBOM subject does not contain sha256:{file_sha256}") + file_evidence = { "name": path.name, "path": str(path), - "sha256": binary_sha256, + "sha256": file_sha256, "sbom": { "asset_name": sbom.name, "subject": path.name, @@ -519,7 +716,16 @@ def render_capsule( "sha256": sha256(sbom), }, } - binaries.append(binary) + if path.name == expected_image_lock_name: + file_evidence["kind"] = "registryctl-release-image-lock" + release_files.append(file_evidence) + else: + binaries.append(file_evidence) + + if require_registryctl_image_lock and len(release_files) != 1: + raise ValueError( + f"capsule requires exactly one registryctl release image lock named {expected_image_lock_name}" + ) images = [] for digest_path in sorted(image_evidence_dir.rglob("*.digest")): @@ -573,6 +779,14 @@ def render_capsule( errors, source = source_lineage(manifest, tag, repo, default_branch) if errors: raise ValueError("; ".join(errors)) + for release_file in release_files: + validate_registryctl_image_lock_document( + load_json(Path(release_file["path"])), + version=version, + release_tag=tag, + manifest_source_ref=str(manifest["stack"]["source_ref"]), + tag_target=source["source_commit"], + ) evidence = { "release_tag": tag, "version": version, @@ -590,6 +804,7 @@ def render_capsule( "default_branch_protection": {"status": default_branch_protection}, "external_pinned_inputs": manifest.get("external", {}), "binaries": binaries, + "release_files": release_files, "images": images, "warnings": manifest.get("warnings", []), "hosted_publication_status": hosted_publication_status(manifest), @@ -631,6 +846,14 @@ def capsule_markdown(evidence: dict) -> str: f"- `{binary['name']}` sha256 `{binary['sha256']}`; " f"SBOM `{binary['sbom']['asset_name']}`" ) + release_files = evidence.get("release_files") or [] + if release_files: + lines.extend(["", "## Release files", ""]) + for release_file in release_files: + lines.append( + f"- `{release_file['name']}` ({release_file['kind']}) sha256 " + f"`{release_file['sha256']}`; SBOM `{release_file['sbom']['asset_name']}`" + ) lines.extend(["", "## Images", ""]) for image in evidence["images"]: scan = image["vulnerability_scan"] @@ -715,6 +938,19 @@ def main() -> int: bind_file_spdx_parser.add_argument("spdx", type=Path) bind_file_spdx_parser.add_argument("--file-name", required=True) bind_file_spdx_parser.add_argument("--sha256", required=True) + image_lock_parser = subparsers.add_parser("render-registryctl-image-lock") + image_lock_parser.add_argument("manifest", type=Path) + image_lock_parser.add_argument("--relay-digest", type=Path, required=True) + image_lock_parser.add_argument("--notary-digest", type=Path, required=True) + image_lock_parser.add_argument("--tag-target", required=True) + image_lock_parser.add_argument("--output", type=Path, required=True) + image_lock_version_parser = subparsers.add_parser( + "verify-registryctl-image-lock-release-version" + ) + image_lock_version_parser.add_argument("--version", required=True) + binary_version_parser = subparsers.add_parser("verify-registryctl-binary-version") + binary_version_parser.add_argument("binary", type=Path) + binary_version_parser.add_argument("--version", required=True) capsule_parser = subparsers.add_parser("render-capsule") capsule_parser.add_argument("manifest", type=Path) capsule_parser.add_argument("--tag", required=True) @@ -730,6 +966,7 @@ def main() -> int: capsule_parser.add_argument("--default-branch-protection", default="unverified") capsule_parser.add_argument("--repo", type=Path, default=ROOT) capsule_parser.add_argument("--default-branch", default="origin/main") + capsule_parser.add_argument("--require-registryctl-image-lock", action="store_true") args = parser.parse_args() if args.command == "validate": @@ -744,6 +981,18 @@ def main() -> int: return bind_spdx_subject(args.spdx, args.image_name, args.digest_ref) if args.command == "bind-spdx-file-subject": return bind_spdx_file_subject(args.spdx, args.file_name, args.sha256) + if args.command == "render-registryctl-image-lock": + return render_registryctl_image_lock( + args.manifest, + args.relay_digest, + args.notary_digest, + args.tag_target, + args.output, + ) + if args.command == "verify-registryctl-image-lock-release-version": + return verify_registryctl_image_lock_release_version(args.version) + if args.command == "verify-registryctl-binary-version": + return verify_registryctl_binary_version(args.binary, args.version) if args.command == "render-capsule": return render_capsule( args.manifest, @@ -760,6 +1009,7 @@ def main() -> int: args.default_branch_protection, args.repo, args.default_branch, + args.require_registryctl_image_lock, ) raise AssertionError(args.command) diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 7420ae21..4bfcb976 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -32,6 +32,15 @@ def test_contributing_documents_repeatable_build_policy(self) -> None: self.assertIn("exactly the same bit-for-bit result", text) self.assertIn(".github/workflows/release.yml", text) + def test_registryctl_alternate_installer_uses_the_target_release_tag(self) -> None: + text = (ROOT / "crates/registryctl/README.md").read_text(encoding="utf-8") + + self.assertIn( + "refs/tags/vX.Y.Z/crates/registryctl/install.sh | " + "REGISTRYCTL_VERSION=vX.Y.Z bash", + text, + ) + def test_release_image_packaging_keeps_lab_dockerfiles_source_building(self) -> None: workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") release_dockerfiles = [ @@ -69,18 +78,54 @@ def test_release_workflow_publishes_cross_platform_registryctl_binaries(self) -> self.assertIn(asset, workflow) self.assertIn(f"registry-stack-registryctl-{asset}", workflow) - def test_release_workflow_publishes_digest_bound_binary_sboms(self) -> None: + def test_release_workflow_publishes_digest_bound_release_file_sboms(self) -> None: workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") backfill = (ROOT / ".github/workflows/release-capsule-backfill.yml").read_text( encoding="utf-8" ) - self.assertIn("Generate binary SBOMs", workflow) + self.assertIn("Generate release file SBOMs", workflow) self.assertIn("dist/binary-sbom", workflow) self.assertIn("Generate image binary SBOMs", workflow) self.assertIn("dist/image-binary-sbom", workflow) self.assertIn("image-input-${asset}.spdx.json", workflow) self.assertIn("bind-spdx-file-subject", workflow) + self.assertIn("render-registryctl-image-lock", workflow) + self.assertIn("verify-registryctl-image-lock-release-version", workflow) + self.assertIn("verify-registryctl-binary-version", workflow) + self.assertIn( + 'chmod 0755 "dist/bin/registryctl-${{ needs.verify.outputs.tag }}-linux-amd64"', + workflow, + ) + self.assertLess( + workflow.index("Verify lock-bearing release version"), + workflow.index("\n binaries:"), + ) + self.assertLess( + workflow.index("Verify built registryctl binary version"), + workflow.index("Upload binary artifacts"), + ) + images_job = workflow[workflow.index("\n images:") : workflow.index("\n github-release:")] + self.assertIn("needs:\n - verify\n - binaries", images_job) + self.assertIn("Build, push, and scan images", images_job) + self.assertIn("--require-registryctl-image-lock", workflow) + self.assertIn("registryctl-${{ needs.verify.outputs.tag }}-image-lock.json", workflow) + self.assertLess( + workflow.index("Verify registryctl binary version"), + workflow.index("Render registryctl release image lock"), + ) + self.assertLess( + workflow.index("Download image evidence"), + workflow.index("Render registryctl release image lock"), + ) + self.assertLess( + workflow.index("Render registryctl release image lock"), + workflow.index("Refresh release file checksums"), + ) + self.assertLess( + workflow.index("Refresh release file checksums"), + workflow.index("Generate release file SBOMs"), + ) self.assertIn("Generate digest-bound binary SBOMs", backfill) self.assertIn("dist/staged/binary-sbom", backfill) self.assertIn("--binary-sbom-dir", backfill) @@ -116,6 +161,173 @@ def test_validate_rejects_head_for_non_draft_release(self) -> None: self.assertNotEqual(0, result.returncode) self.assertIn("stack.source_ref may be HEAD only", result.stderr) + def test_render_registryctl_image_lock_from_exact_release_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = write_manifest(root, version="0.9.0") + relay_digest = root / "registry-relay.digest" + notary_digest = root / "registry-notary.digest" + relay_ref = f"ghcr.io/registrystack/registry-relay@{IMAGE_DIGEST}" + notary_ref = f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}" + relay_digest.write_text(f"{relay_ref}\n", encoding="utf-8") + notary_digest.write_text(f"{notary_ref}\n", encoding="utf-8") + output = root / "registryctl-v0.9.0-image-lock.json" + + result = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--notary-digest", + str(notary_digest), + "--tag-target", + "b" * 40, + "--output", + str(output), + ) + document = json.loads(output.read_text(encoding="utf-8")) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual( + { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.9.0", + "manifest_source_ref": "f30a541df539c2e16de09733c5944c744a60493c", + "tag_target": "b" * 40, + "platform": "linux/amd64", + "images": { + "registry-relay": relay_ref, + "registry-notary": notary_ref, + }, + }, + document, + ) + + def test_registryctl_image_lock_release_version_gate(self) -> None: + rejected = run_tool( + "verify-registryctl-image-lock-release-version", + "--version", + "0.8.5", + ) + accepted = run_tool( + "verify-registryctl-image-lock-release-version", + "--version", + "0.9.0", + ) + + self.assertNotEqual(0, rejected.returncode) + self.assertIn("require version 0.9.0 or later", rejected.stderr) + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertIn("verified registryctl image lock release version 0.9.0", accepted.stdout) + + def test_render_registryctl_image_lock_rejects_pre_0_9_release(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = write_manifest(root, version="0.8.5") + relay_digest = root / "registry-relay.digest" + notary_digest = root / "registry-notary.digest" + relay_digest.write_text( + f"ghcr.io/registrystack/registry-relay@{IMAGE_DIGEST}\n", + encoding="utf-8", + ) + notary_digest.write_text( + f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}\n", + encoding="utf-8", + ) + output = root / "registryctl-v0.8.5-image-lock.json" + + result = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--notary-digest", + str(notary_digest), + "--tag-target", + "b" * 40, + "--output", + str(output), + ) + + self.assertFalse(output.exists()) + + self.assertNotEqual(0, result.returncode) + self.assertIn("require version 0.9.0 or later", result.stderr) + + def test_verify_registryctl_binary_version_matches_manifest_version(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + binary = Path(tmp) / "registryctl" + binary.write_text("#!/bin/sh\nprintf 'registryctl 0.8.0\\n'\n", encoding="utf-8") + binary.chmod(0o755) + + matching = run_tool( + "verify-registryctl-binary-version", + str(binary), + "--version", + "0.8.0", + ) + mismatch = run_tool( + "verify-registryctl-binary-version", + str(binary), + "--version", + "0.9.0", + ) + + self.assertEqual(0, matching.returncode, matching.stderr) + self.assertIn("verified registryctl binary version 0.8.0", matching.stdout) + self.assertNotEqual(0, mismatch.returncode) + self.assertIn( + "registryctl binary version must be exactly 'registryctl 0.9.0'", + mismatch.stderr, + ) + + def test_render_registryctl_image_lock_rejects_wrong_repository_and_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = write_manifest(root, version="0.9.0") + relay_digest = root / "registry-relay.digest" + notary_digest = root / "registry-notary.digest" + relay_digest.write_text( + f"ghcr.io/example/registry-relay@{IMAGE_DIGEST}\n", encoding="utf-8" + ) + notary_digest.write_text( + f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}\n", + encoding="utf-8", + ) + + wrong_repo = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--notary-digest", + str(notary_digest), + "--tag-target", + "b" * 40, + "--output", + str(root / "registryctl-v0.9.0-image-lock.json"), + ) + relay_digest.write_text( + f"ghcr.io/registrystack/registry-relay@{IMAGE_DIGEST}\n", encoding="utf-8" + ) + wrong_name = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--notary-digest", + str(notary_digest), + "--tag-target", + "b" * 40, + "--output", + str(root / "image-lock.json"), + ) + + self.assertNotEqual(0, wrong_repo.returncode) + self.assertIn("repository must be ghcr.io/registrystack/registry-relay", wrong_repo.stderr) + self.assertNotEqual(0, wrong_name.returncode) + self.assertIn("output filename must be registryctl-v0.9.0-image-lock.json", wrong_name.stderr) + def test_validate_source_accepts_ancestor_source_ref(self) -> None: with tempfile.TemporaryDirectory() as tmp: repo = init_repo(Path(tmp)) @@ -201,6 +413,70 @@ def test_render_capsule_combines_binary_and_image_evidence(self) -> None: self.assertNotIn("signing `", capsule_markdown) self.assertNotIn("attestation `", capsule_markdown) + def test_render_capsule_classifies_required_image_lock_as_release_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_ref = init_release_repo(root) + tag_target = git(root, "rev-parse", "v0.8.0^{commit}") + manifest = write_manifest(root, source_ref=source_ref) + binary_dir = write_binary_fixture(root) + add_registryctl_image_lock_fixture( + binary_dir, + manifest_source_ref=source_ref, + tag_target=tag_target, + ) + binary_sbom_dir = write_binary_sbom_fixture(root, binary_dir) + image_dir = write_image_fixture(root) + output_json = root / "capsule.json" + output_md = root / "capsule.md" + + result = render_capsule( + manifest, + binary_dir, + image_dir, + output_json, + output_md, + root, + binary_sbom_dir=binary_sbom_dir, + require_registryctl_image_lock=True, + ) + evidence = json.loads(output_json.read_text(encoding="utf-8")) + markdown = output_md.read_text(encoding="utf-8") + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(evidence["binaries"])) + self.assertEqual(1, len(evidence["release_files"])) + release_file = evidence["release_files"][0] + self.assertEqual("registryctl-release-image-lock", release_file["kind"]) + self.assertEqual("registryctl-v0.8.0-image-lock.json", release_file["name"]) + self.assertEqual( + "registryctl-v0.8.0-image-lock.json.spdx.json", + release_file["sbom"]["asset_name"], + ) + self.assertNotIn(release_file["name"], {item["name"] for item in evidence["binaries"]}) + self.assertIn("## Release files", markdown) + + def test_render_capsule_required_image_lock_fails_when_omitted(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source_ref = init_release_repo(root) + manifest = write_manifest(root, source_ref=source_ref) + binary_dir = write_binary_fixture(root) + image_dir = write_image_fixture(root) + + result = render_capsule( + manifest, + binary_dir, + image_dir, + root / "capsule.json", + root / "capsule.md", + root, + require_registryctl_image_lock=True, + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("requires exactly one registryctl release image lock", result.stderr) + def test_render_capsule_includes_cross_platform_binaries(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -372,7 +648,7 @@ def test_render_capsule_rejects_missing_binary_sbom(self) -> None: ) self.assertNotEqual(0, result.returncode) - self.assertIn("missing a binary SBOM file", result.stderr) + self.assertIn("missing a file SBOM", result.stderr) def test_render_capsule_rejects_binary_sbom_without_digest_subject(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -543,6 +819,27 @@ def test_stage_capsule_backfill_assets_stages_optional_cross_platform_binaries(s # Required amd64 binaries are still staged alongside the optional ones. self.assertTrue((binary_dir / "registryctl-v0.8.0-linux-amd64").is_file()) + def test_stage_capsule_backfill_assets_stages_optional_registryctl_image_lock(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + asset_dir = write_release_asset_fixture(root, include_image_lock=True) + binary_dir = root / "staged-bin" + + result = run_tool( + "stage-capsule-backfill-assets", + str(asset_dir), + "--tag", + "v0.8.0", + "--binary-dir", + str(binary_dir), + "--image-evidence-dir", + str(root / "staged-images"), + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue((binary_dir / "registryctl-v0.8.0-image-lock.json").is_file()) + self.assertIn("1/1 optional release files", result.stdout) + def test_stage_capsule_backfill_assets_rejects_missing_release_asset(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -691,21 +988,24 @@ def write_manifest( directory: Path, *, source_ref: str = "f30a541df539c2e16de09733c5944c744a60493c", - source_tag: str = "v0.8.0", + source_tag: str | None = None, status: str = "release-candidate", + version: str = "0.8.0", ) -> Path: + if source_tag is None: + source_tag = f"v{version}" manifest = { "stack": { "release": "beta-6", - "version": "0.8.0", + "version": version, "source_repo": "registrystack/registry-stack", "source_ref": source_ref, "source_tag": source_tag, "status": status, }, "artifacts": { - "registry-notary": "0.8.0", - "registry-relay": "0.8.0", + "registry-notary": version, + "registry-relay": version, }, "external": { "crosswalk": { @@ -730,6 +1030,42 @@ def write_binary_fixture(root: Path) -> Path: return binary_dir +def add_registryctl_image_lock_fixture( + binary_dir: Path, + *, + manifest_source_ref: str, + tag_target: str, +) -> Path: + image_lock = binary_dir / "registryctl-v0.8.0-image-lock.json" + image_lock.write_text( + json.dumps( + { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.8.0", + "manifest_source_ref": manifest_source_ref, + "tag_target": tag_target, + "platform": "linux/amd64", + "images": { + "registry-relay": f"ghcr.io/registrystack/registry-relay@{IMAGE_DIGEST}", + "registry-notary": f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}", + }, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + checksums = [] + for path in sorted(binary_dir.iterdir()): + if path.is_file() and path.name != "SHA256SUMS": + checksums.append( + subprocess.check_output(["sha256sum", path.name], cwd=binary_dir, text=True) + ) + (binary_dir / "SHA256SUMS").write_text("".join(checksums), encoding="utf-8") + return image_lock + + def write_multiplatform_binary_fixture(root: Path) -> Path: binary_dir = root / "bin" binary_dir.mkdir() @@ -786,7 +1122,12 @@ def write_binary_sbom_fixture(root: Path, binary_dir: Path) -> Path: return sbom_dir -def write_release_asset_fixture(root: Path, *, include_cross_platform: bool = False) -> Path: +def write_release_asset_fixture( + root: Path, + *, + include_cross_platform: bool = False, + include_image_lock: bool = False, +) -> Path: asset_dir = root / "release-assets" asset_dir.mkdir() binary_names = [ @@ -800,6 +1141,8 @@ def write_release_asset_fixture(root: Path, *, include_cross_platform: bool = Fa "registryctl-v0.8.0-macos-arm64", "registryctl-v0.8.0-linux-arm64", ] + if include_image_lock: + binary_names.append("registryctl-v0.8.0-image-lock.json") checksums = [] for name in binary_names: path = asset_dir / name @@ -873,10 +1216,11 @@ def render_capsule( repo: Path, *, binary_sbom_dir: Path | None = None, + require_registryctl_image_lock: bool = False, ) -> subprocess.CompletedProcess[str]: if binary_sbom_dir is None: binary_sbom_dir = write_binary_sbom_fixture(repo, binary_dir) - return run_tool( + args = [ "render-capsule", str(manifest), "--tag", @@ -901,7 +1245,10 @@ def render_capsule( str(repo), "--default-branch", "main", - ) + ] + if require_registryctl_image_lock: + args.append("--require-registryctl-image-lock") + return run_tool(*args) if __name__ == "__main__":