From 40fc215a17e9e5c6d8d078b2ceeb06e949616f94 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 4 Oct 2026 13:21:51 +0200 Subject: [PATCH] feat: pack the project's share of the images lock in publish-fragment --- .github/workflows/publish-fragment.yml | 23 +++++++++++++++++++- actions/publish-fragment/pack.sh | 9 ++++++++ tests/test_publish_fragment.py | 29 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-fragment.yml b/.github/workflows/publish-fragment.yml index e09b64c..de52ba0 100644 --- a/.github/workflows/publish-fragment.yml +++ b/.github/workflows/publish-fragment.yml @@ -2,7 +2,8 @@ # composition (deploy-kit spec/v1/40-composition.md, spec/v1/55-delivery.md). # # release tag -> images built -> this workflow: -# validate -> pack with the release's version -> push -> sign keyless +# validate -> pack with the release's version and the project's share of +# the images lock -> push -> sign keyless # -> read back and verify -> dispatch composition in the Estate repository # # Call it once per project file, after the release's images exist. A merge that @@ -48,6 +49,16 @@ on: required: false type: string default: "" + images-lock-artifact: + description: >- + Name of an uploaded artifact holding images.lock.yml: every image this + release's build pushed, by digest, with the user it runs as. The + project's share of it is packed into the fragment. Left empty, the + fragment carries no share and the Platform document's lock must hold + the project's images. + required: false + type: string + default: "" toolkit-directory: description: The directory holding the package.json and package-lock.json that pin @jorisjonkers-dev/deploy-kit. required: false @@ -154,9 +165,19 @@ jobs: } cp .migration-proof/migration-proof.yml "$(dirname "$PROJECT_FILE")/migration-proof.yml" + - name: Fetch the images lock + if: ${{ inputs.images-lock-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.images-lock-artifact }} + path: ${{ runner.temp }}/images-lock + - name: Validate and pack id: pack env: + # The lock stays outside the checkout: only the project's share of it + # goes into the fragment, never the file itself. + IMAGES_LOCK: ${{ inputs.images-lock-artifact != '' && format('{0}/images-lock/images.lock.yml', runner.temp) || '' }} PROJECT_FILE: ${{ inputs.project-file }} VALIDATE_WITH: ${{ inputs.validate-with }} VERSION: ${{ inputs.version }} diff --git a/actions/publish-fragment/pack.sh b/actions/publish-fragment/pack.sh index 98a9e82..02a2f78 100755 --- a/actions/publish-fragment/pack.sh +++ b/actions/publish-fragment/pack.sh @@ -11,6 +11,7 @@ set -euo pipefail : "${PROJECT_FILE:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${REPOSITORY:?}" "${OUT:?}" VALIDATE_WITH="${VALIDATE_WITH:-}" +IMAGES_LOCK="${IMAGES_LOCK:-}" TOOLKIT_DIRECTORY="${TOOLKIT_DIRECTORY:-.}" DEPLOY_KIT_COMMAND="${DEPLOY_KIT_COMMAND:-npx --no-install deploy-kit}" @@ -37,6 +38,13 @@ version="${VERSION#v}" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version '${VERSION}' is not a release, vX.Y.Z" [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "source-sha '${SOURCE_SHA}' is not a commit" [ -f "$PROJECT_FILE" ] || fail "no project file at ${PROJECT_FILE}" +# The lock the release's build wrote. The command packs the project's share of +# it and refuses an alias it does not hold. +share=() +if [ -n "$IMAGES_LOCK" ]; then + [ -f "$IMAGES_LOCK" ] || fail "no images lock at ${IMAGES_LOCK}" + share=(--images-lock "$(absolute "$IMAGES_LOCK")") +fi # The project file and what is read with it: env files, Assets. A directory is # read as every file below it. @@ -60,6 +68,7 @@ deploy_kit publish "$(absolute "$PROJECT_FILE")" \ --repository "$REPOSITORY" \ --source-sha "$SOURCE_SHA" \ --version "$version" \ + ${share[@]+"${share[@]}"} \ --out "$out" [ -f "$out/fragment.yml" ] || fail "the command packed no fragment.yml" diff --git a/tests/test_publish_fragment.py b/tests/test_publish_fragment.py index 127caf6..0ddf1b6 100644 --- a/tests/test_publish_fragment.py +++ b/tests/test_publish_fragment.py @@ -79,6 +79,26 @@ def test_validates_then_packs_the_release_and_writes_a_manifest(self): (self.dir / "output").read_text(), "project=notes\nversion=1.4.0\ninputs-sha=abc123\n" ) + def test_the_images_lock_is_handed_to_the_command_only_when_there_is_one(self): + run = self.run_pack() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertNotIn("--images-lock", self.log.read_text()) + + lock = self.dir / "built" / "images.lock.yml" + lock.parent.mkdir() + lock.write_text("kind: ImagesLock\n") + self.log.write_text("") + run = self.run_pack(IMAGES_LOCK="built/images.lock.yml") + self.assertEqual(run.returncode, 0, run.stderr) + publish = self.log.read_text().splitlines()[1] + self.assertIn(f"--version 1.4.0 --images-lock {lock} --out {self.dir}/fragment", publish) + + def test_a_lock_that_is_named_and_not_there_packs_nothing(self): + run = self.run_pack(IMAGES_LOCK="built/images.lock.yml") + self.assertEqual(run.returncode, 1) + self.assertIn("no images lock at built/images.lock.yml", run.stderr) + self.assertEqual(self.log.read_text() if self.log.exists() else "", "") + def test_a_refused_project_file_packs_nothing(self): run = self.run_pack(STUB_REFUSE_VALIDATE="1") self.assertEqual(run.returncode, 1) @@ -273,6 +293,15 @@ def test_every_third_party_action_is_pinned_to_a_commit(self): for uses in re.findall(r"uses: (\S+)", self.text): self.assertRegex(uses, r"@[0-9a-f]{40}$", uses) + def test_the_lock_is_fetched_outside_the_checkout_and_only_when_named(self): + self.assertIn("if: ${{ inputs.images-lock-artifact != '' }}", self.text) + self.assertIn("path: ${{ runner.temp }}/images-lock", self.text) + self.assertIn( + "IMAGES_LOCK: ${{ inputs.images-lock-artifact != '' && " + "format('{0}/images-lock/images.lock.yml', runner.temp) || '' }}", + self.text, + ) + def test_composition_is_started_with_the_dispatch_app_only(self): self.assertIn("app-id: ${{ vars.ESTATE_DISPATCH_APP_ID }}", self.text) self.assertIn("repositories: estate", self.text)