diff --git a/.github/workflows/publish-fragment.yml b/.github/workflows/publish-fragment.yml index 651d1ac..b8b95bc 100644 --- a/.github/workflows/publish-fragment.yml +++ b/.github/workflows/publish-fragment.yml @@ -9,9 +9,9 @@ # Call it once per project file, after the release's images exist. A merge that # is not released publishes nothing: the caller triggers on the tag. # -# Every decision is a deploy-kit command, at the version the calling -# repository's own lockfile pins. The scripts beside it only hand the command -# its arguments and move bytes. +# Every decision is a deploy-kit command, at the version tools/deploy-kit in +# this repository pins; a calling repository pins none. The scripts beside it +# only hand the command its arguments and move bytes. # # The signature's identity is this workflow, run for the calling repository, so # composition verifies every fragment against one subject and reads the @@ -68,11 +68,6 @@ on: 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 - type: string - default: "." node-version: description: The Node the toolkit runs on. required: false @@ -139,20 +134,18 @@ jobs: registry-url: https://npm.pkg.github.com scope: "@jorisjonkers-dev" - - name: Install the toolkit at the version this repository pins - working-directory: ${{ inputs.toolkit-directory }} + - name: Install the toolkit at the version this workflow pins + working-directory: .github-workflows/tools/deploy-kit env: NODE_AUTH_TOKEN: ${{ github.token }} - TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }} run: | - # The version lives in one place, the caller's lockfile, which Renovate - # moves. Nothing here names a version, and `npx --no-install` below - # can only run what this step installed. + # The version lives in tools/deploy-kit/package-lock.json, which Renovate moves. npm ci --ignore-scripts + echo "::notice::deploy-kit $(jq -r .version node_modules/@jorisjonkers-dev/deploy-kit/package.json)" # Called with nothing to do, the command answers with its usage and # exits 2. Anything else means the pinned release has no command. npx --no-install deploy-kit >/dev/null 2>&1 || [ "$?" -eq 2 ] || { - echo "::error::@jorisjonkers-dev/deploy-kit in ${TOOLKIT_DIRECTORY}/package-lock.json ships no deploy-kit command; pin a release that does" + echo "::error::the pinned @jorisjonkers-dev/deploy-kit ships no deploy-kit command; pin a release that does" exit 1 } @@ -233,7 +226,7 @@ jobs: VERSION: ${{ inputs.version }} SOURCE_SHA: ${{ inputs.ref || github.sha }} REPOSITORY: ${{ github.repository }} - TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }} + TOOLKIT_DIRECTORY: .github-workflows/tools/deploy-kit OUT: ${{ runner.temp }}/fragment run: bash .github-workflows/actions/publish-fragment/pack.sh diff --git a/README.md b/README.md index 68deda3..f014742 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ should call released tags instead of branches. | `production-canary.yml` | Run caller-owned production smoke checks. | | `deploy-bundle.yml` | Validate first-party deploy bundles and optionally publish them to GHCR. | | `deploy-sources-render.yml` | Render deployment sources and expose image tags for downstream tests. | -| `publish-fragment.yml` | Validate, pack, sign and push a project file's Intent Fragment for a release, then start composition (deploy-kit). | +| `publish-fragment.yml` | Validate, pack, sign and push a project file's Intent Fragment for a release, then start composition (deploy-kit). Runs deploy-kit at the version `tools/deploy-kit` pins; the caller pins none. | | `path-buckets.yml` | The `changes` job of a bucketed pipeline: its `run` output gates each job on the buckets a diff reaches. | | `repository-hygiene-guard.yml` | Block reintroduction of planning and scratch artifacts. | | `add-to-project.yml` | Add opened/reopened issues and pull requests to the org Project. | diff --git a/actions/publish-fragment/pack.sh b/actions/publish-fragment/pack.sh index 301fc82..710bbe0 100755 --- a/actions/publish-fragment/pack.sh +++ b/actions/publish-fragment/pack.sh @@ -5,8 +5,8 @@ # version's shape, hands the command its arguments, and writes the per-file # manifest a consumer verifies the pulled package against. # -# The toolkit is the one the calling repository's lockfile pins. `npm ci` in -# TOOLKIT_DIRECTORY installed it, and `npx --no-install` can run nothing else. +# The toolkit is the one tools/deploy-kit pins. `npm ci` in TOOLKIT_DIRECTORY +# installed it, and `npx --no-install` can run nothing else. set -euo pipefail : "${PROJECT_FILE:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${REPOSITORY:?}" "${OUT:?}" diff --git a/renovate.json b/renovate.json index e2b1864..5218ca5 100644 --- a/renovate.json +++ b/renovate.json @@ -18,6 +18,20 @@ "before 9am on monday" ] }, + { + "description": "The deploy-kit publish-fragment.yml runs: a bump is a release of this repository, merged only once JorisJonkers-dev/estate composes with that version or newer", + "matchFileNames": [ + "tools/deploy-kit/**" + ], + "matchPackageNames": [ + "@jorisjonkers-dev/deploy-kit" + ], + "semanticCommitType": "fix", + "automerge": false, + "prBodyNotes": [ + "Merge only once `JorisJonkers-dev/estate`'s `package.json` pins this deploy-kit version or newer: composition refuses a fragment whose model minor is newer than its own toolkit's." + ] + }, { "description": "JorisJonkers-dev/github-workflows: track semver tags, do NOT pin to digest", "matchPackageNames": [ diff --git a/tests/test_publish_fragment.py b/tests/test_publish_fragment.py index f085a41..492ef19 100644 --- a/tests/test_publish_fragment.py +++ b/tests/test_publish_fragment.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json import os import re import stat @@ -356,6 +357,24 @@ def test_the_delivery_is_titled_with_the_release_that_started_it(self): step = self.text[start:] self.assertEqual(step.count("gh workflow run compose.yml"), 2) + def test_the_toolkit_is_the_one_this_repository_pins(self): + self.assertNotIn("toolkit-directory", self.text) + step = self.text.split("- name: Install the toolkit at the version this workflow pins", 1)[1].split("- name:", 1)[0] + self.assertIn("working-directory: .github-workflows/tools/deploy-kit", step) + self.assertIn("npm ci --ignore-scripts", step) + self.assertIn('echo "::notice::deploy-kit $(jq -r .version node_modules/@jorisjonkers-dev/deploy-kit/package.json)"', step) + self.assertIn("TOOLKIT_DIRECTORY: .github-workflows/tools/deploy-kit", self.text) + + def test_the_pin_is_exact_and_locked(self): + toolkit = ROOT / "tools" / "deploy-kit" + pinned = json.loads((toolkit / "package.json").read_text())["devDependencies"]["@jorisjonkers-dev/deploy-kit"] + self.assertRegex(pinned, r"^\d+\.\d+\.\d+$") + lock = json.loads((toolkit / "package-lock.json").read_text()) + self.assertEqual(lock["packages"][""]["devDependencies"]["@jorisjonkers-dev/deploy-kit"], pinned) + locked = lock["packages"]["node_modules/@jorisjonkers-dev/deploy-kit"] + self.assertEqual(locked["version"], pinned) + self.assertIn("deploy-kit", locked["bin"]) + if __name__ == "__main__": unittest.main() diff --git a/tools/deploy-kit/package-lock.json b/tools/deploy-kit/package-lock.json new file mode 100644 index 0000000..65a540d --- /dev/null +++ b/tools/deploy-kit/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "publish-fragment-toolkit", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "publish-fragment-toolkit", + "devDependencies": { + "@jorisjonkers-dev/deploy-kit": "0.14.0" + } + }, + "node_modules/@jorisjonkers-dev/deploy-kit": { + "version": "0.14.0", + "resolved": "https://npm.pkg.github.com/download/@jorisjonkers-dev/deploy-kit/0.14.0/1da33f6b728d77b6501478cff5ab1c4fad6e93c6", + "integrity": "sha512-LVjoEs3OIQ3D80Md9ngpuXBtuJ/f2QK3l5MpmPwLzZLQ+meFUye39b3B5cVCLKiBrF/w56d/ooJYnJy2McsG0g==", + "dev": true, + "license": "LicenseRef-JorisJonkers-Proprietary-1.0", + "dependencies": { + "yaml": "2.9.1", + "zod": "4.6.5" + }, + "bin": { + "deploy-kit": "dist/cli/index.js" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tools/deploy-kit/package.json b/tools/deploy-kit/package.json new file mode 100644 index 0000000..dc889b5 --- /dev/null +++ b/tools/deploy-kit/package.json @@ -0,0 +1,8 @@ +{ + "name": "publish-fragment-toolkit", + "private": true, + "description": "Pins the deploy-kit release publish-fragment.yml validates and packs every Intent Fragment with.", + "devDependencies": { + "@jorisjonkers-dev/deploy-kit": "0.14.0" + } +}