From 5f34307732a594ab82c9cb43e52ef2e73d4aae3a Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 3 Oct 2026 13:22:39 +0200 Subject: [PATCH 1/3] feat: publish-fragment workflow and the render-diff step for deploy-kit projects --- .github/workflows/publish-fragment.yml | 215 ++++++++++++++++++ README.md | 2 + actions/publish-fragment/pack.sh | 80 +++++++ actions/publish-fragment/push.sh | 66 ++++++ actions/render-diff/action.yml | 79 +++++++ actions/render-diff/comment.sh | 31 +++ actions/render-diff/run.sh | 158 +++++++++++++ release-please-config.json | 4 + tests/fixtures/estate-delivery/deploy-kit | 64 ++++++ tests/test_publish_fragment.py | 257 ++++++++++++++++++++++ tests/test_render_diff.py | 222 +++++++++++++++++++ 11 files changed, 1178 insertions(+) create mode 100644 .github/workflows/publish-fragment.yml create mode 100755 actions/publish-fragment/pack.sh create mode 100755 actions/publish-fragment/push.sh create mode 100644 actions/render-diff/action.yml create mode 100755 actions/render-diff/comment.sh create mode 100755 actions/render-diff/run.sh create mode 100755 tests/fixtures/estate-delivery/deploy-kit create mode 100644 tests/test_publish_fragment.py create mode 100644 tests/test_render_diff.py diff --git a/.github/workflows/publish-fragment.yml b/.github/workflows/publish-fragment.yml new file mode 100644 index 0000000..e09b64c --- /dev/null +++ b/.github/workflows/publish-fragment.yml @@ -0,0 +1,215 @@ +# Publishes one project file's Intent Fragment for one release, and starts +# 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 +# -> 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 +# 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. +# +# The signature's identity is this workflow, run for the calling repository, so +# composition verifies every fragment against one subject and reads the +# repository from the certificate. +# +# ONE job: jobs are billed by the minute, rounded up. +name: Publish Fragment + +on: + workflow_call: + inputs: + project-file: + description: The project file to publish, relative to the repository root. + required: true + type: string + version: + description: The release, as its tag (vX.Y.Z) or bare (X.Y.Z). + required: true + type: string + ref: + description: The commit the release's images were built from. Defaults to the commit that triggered the caller. + required: false + type: string + default: "" + validate-with: + description: Paths read together with the project file when it is validated, space separated; a directory is read as every file below it. + required: false + type: string + default: "" + migration-proof-artifact: + description: >- + Name of an uploaded artifact holding migration-proof.yml, written by + JorisJonkers-dev/liquibase-runner's migration-proof action earlier in the + caller's run. It lands beside the project file before the fragment is packed. + 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 + type: string + default: "24" + compose: + description: Start composition in the Estate repository once the fragment is published. + required: false + type: boolean + default: true + secrets: + ESTATE_DISPATCH_APP_PRIVATE_KEY: + description: The dispatch App's private key (an organization secret). Required when compose is true. + required: false + outputs: + ref: + description: The published fragment, by digest. + value: ${{ jobs.publish.outputs.ref }} + digest: + description: The published fragment's digest. + value: ${{ jobs.publish.outputs.digest }} + project: + description: The Project the fragment declares. + value: ${{ jobs.publish.outputs.project }} + +permissions: {} + +concurrency: + # One publish per project file at a time, so `latest` is never raced. + group: publish-fragment-${{ github.repository }}-${{ inputs.project-file }} + cancel-in-progress: false + +jobs: + publish: + name: Publish Fragment + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + id-token: write + outputs: + ref: ${{ steps.push.outputs.ref }} + digest: ${{ steps.push.outputs.digest }} + project: ${{ steps.pack.outputs.project }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + + # This repository at the release the caller pinned, so the scripts and the + # workflow file are the same version. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: JorisJonkers-dev/github-workflows + ref: v0.18.0 # x-release-please-version + path: .github-workflows + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + 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 }} + 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. + npm ci --ignore-scripts + # 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" + exit 1 + } + + - name: Fetch the migration proof + if: ${{ inputs.migration-proof-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.migration-proof-artifact }} + path: .migration-proof + + - name: Put the migration proof beside the project file + if: ${{ inputs.migration-proof-artifact != '' }} + env: + PROJECT_FILE: ${{ inputs.project-file }} + run: | + test -f .migration-proof/migration-proof.yml || { + echo "::error::the artifact holds no migration-proof.yml" + exit 1 + } + cp .migration-proof/migration-proof.yml "$(dirname "$PROJECT_FILE")/migration-proof.yml" + + - name: Validate and pack + id: pack + env: + PROJECT_FILE: ${{ inputs.project-file }} + VALIDATE_WITH: ${{ inputs.validate-with }} + VERSION: ${{ inputs.version }} + SOURCE_SHA: ${{ inputs.ref || github.sha }} + REPOSITORY: ${{ github.repository }} + TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }} + OUT: ${{ runner.temp }}/fragment + run: bash .github-workflows/actions/publish-fragment/pack.sh + + - uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2 + with: + version: 1.3.4 + + - uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 + with: + cosign-release: v2.6.1 + + - name: Push, sign and read back + id: push + env: + GITHUB_TOKEN: ${{ github.token }} + FRAGMENT: ${{ runner.temp }}/fragment + PROJECT: ${{ steps.pack.outputs.project }} + VERSION: ${{ steps.pack.outputs.version }} + SOURCE_SHA: ${{ inputs.ref || github.sha }} + OWNER: ${{ github.repository_owner }} + SIGNED_REPOSITORY: ${{ github.repository }} + run: | + echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + echo "$GITHUB_TOKEN" | cosign login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + bash .github-workflows/actions/publish-fragment/push.sh + + # The dispatch App can start a run in the Estate repository and cannot + # push to it (deploy-kit spec/v1/60-setup.md#the-estate-repository). + - name: Mint a token for the Estate repository + id: estate + if: ${{ inputs.compose }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.ESTATE_DISPATCH_APP_ID }} + private-key: ${{ secrets.ESTATE_DISPATCH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: estate + + - name: Start composition + if: ${{ inputs.compose }} + env: + GH_TOKEN: ${{ steps.estate.outputs.token }} + ESTATE: ${{ github.repository_owner }}/estate + FRAGMENT_REF: ${{ steps.push.outputs.ref }} + run: | + # Composition pulls every participant's newest fragment itself; the + # dispatch only says that one moved. It is pushed, not polled: a cron + # in this estate runs hours late. + gh workflow run compose.yml --repo "$ESTATE" --ref main + echo "composition started in ${ESTATE} for ${FRAGMENT_REF}" diff --git a/README.md b/README.md index 4e7c6c8..527b816 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ should call released tags instead of branches. | `actions/api-client-publish` | Generate and publish TypeScript, Java, and Kotlin API clients. | | `actions/deploy-bundle` | Validate and pack a first-party `deploy/` directory as an OCI bundle. | | `actions/deploy-sources-render` | Resolve deployment sources, compile Flux output, and emit image tags. | +| `actions/render-diff` | Compose the estate with a pull request's project file and comment the Project's render diff (deploy-kit). | ## Reusable Workflows @@ -44,6 +45,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). | | `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 new file mode 100755 index 0000000..ec22ed4 --- /dev/null +++ b/actions/publish-fragment/pack.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Validate a project file and pack its Intent Fragment for one release. +# +# Every decision is the deploy-kit command's: this script checks the release +# 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. +set -euo pipefail + +: "${PROJECT_FILE:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${REPOSITORY:?}" "${OUT:?}" +VALIDATE_WITH="${VALIDATE_WITH:-}" +TOOLKIT_DIRECTORY="${TOOLKIT_DIRECTORY:-.}" +DEPLOY_KIT_COMMAND="${DEPLOY_KIT_COMMAND:-npx --no-install deploy-kit}" + +fail() { + echo "publish-fragment: $*" >&2 + exit 1 +} + +workspace="$PWD" +absolute() { + case "$1" in + /*) printf '%s' "$1" ;; + *) printf '%s/%s' "$workspace" "$1" ;; + esac +} + +deploy_kit() { + # shellcheck disable=SC2086 # a command and its fixed options, split on purpose + (cd "$TOOLKIT_DIRECTORY" && $DEPLOY_KIT_COMMAND "$@") +} + +# A release tag is vX.Y.Z; the fragment carries X.Y.Z. +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 project file and what is read with it: env files, Assets. A directory is +# read as every file below it. +read_together=("$(absolute "$PROJECT_FILE")") +for path in $VALIDATE_WITH; do + [ -e "$path" ] || fail "validate-with names ${path}, which does not exist" + read_together+=("$(absolute "$path")") +done + +echo "::group::Validate" +deploy_kit validate "${read_together[@]}" +echo "::endgroup::" + +echo "::group::Pack" +out="$(absolute "$OUT")" +rm -rf "$out" +deploy_kit publish "$(absolute "$PROJECT_FILE")" \ + --repository "$REPOSITORY" \ + --source-sha "$SOURCE_SHA" \ + --version "$version" \ + --out "$out" +[ -f "$out/fragment.yml" ] || fail "the command packed no fragment.yml" + +# Per-file digests, so a consumer can verify the package it pulled. +manifest="$(mktemp)" +(cd "$out" && find . -type f | LC_ALL=C sort | xargs sha256sum) >"$manifest" +mv "$manifest" "$out/MANIFEST.sha256" +echo "::endgroup::" + +project="$(yq '.spec.project' "$out/fragment.yml")" +inputs_sha="$(yq '.spec.inputsSha' "$out/fragment.yml")" +[[ "$project" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "the fragment names project '${project}', which is not a name" + +echo "packed ${project} ${version} (${inputs_sha})" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "project=${project}" + echo "version=${version}" + echo "inputs-sha=${inputs_sha}" + } >>"$GITHUB_OUTPUT" +fi diff --git a/actions/publish-fragment/push.sh b/actions/publish-fragment/push.sh new file mode 100755 index 0000000..7b9e2c6 --- /dev/null +++ b/actions/publish-fragment/push.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Push a packed Intent Fragment, sign it keyless, and read it back. +# +# The fragment is pushed under its release version. `latest`, which composition +# resolves, moves only forward: publishing an older release again leaves it +# where it is, so a re-run can never put an earlier fragment in front of +# composition. Going back is a Rollback in the Estate repository, not a push. +set -euo pipefail + +: "${FRAGMENT:?}" "${PROJECT:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${OWNER:?}" "${SIGNED_REPOSITORY:?}" +SIGNER_WORKFLOW="${SIGNER_WORKFLOW:-https://github.com/JorisJonkers-dev/github-workflows/.github/workflows/publish-fragment.yml@}" + +fail() { + echo "publish-fragment: $*" >&2 + exit 1 +} + +repository="ghcr.io/$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')/intent-${PROJECT}" +inputs_sha="$(yq '.spec.inputsSha' "$FRAGMENT/fragment.yml")" + +# The release `latest` names now, if any. Absent on a Project's first publish. +current="$(oras manifest fetch "${repository}:latest" 2>/dev/null | + jq -r '.annotations["org.opencontainers.image.version"] // ""' || true)" +tags="$VERSION" +if [ -z "$current" ] || [ "$(printf '%s\n%s\n' "$current" "$VERSION" | sort -V | tail -n 1)" = "$VERSION" ]; then + tags="${VERSION},latest" +else + echo "::notice::latest stays at ${current}: ${VERSION} is older, so it is pushed under its own tag only" +fi + +# The digest comes from the push itself, never from a tag another run could move. +digest="$(cd "$FRAGMENT" && oras push "${repository}:${tags}" \ + --annotation "org.opencontainers.image.version=${VERSION}" \ + --annotation "org.opencontainers.image.revision=${SOURCE_SHA}" \ + --annotation "dev.jorisjonkers.inputs-sha=${inputs_sha}" \ + --format go-template='{{.digest}}' .)" +case "$digest" in + sha256:*) ;; + *) fail "the push returned no digest: '${digest}'" ;; +esac + +# Keyless: the identity is this run's OIDC token, so no signing key exists to +# store, rotate or leak. +cosign sign --yes "${repository}@${digest}" + +# A push and a signature that succeeded are not evidence a consumer can read +# and trust what was meant. Pull it back by digest, check every file, and +# verify the signature the way composition will: this workflow's identity, run +# for this repository. +check="$(mktemp -d)" +oras pull "${repository}@${digest}" --output "$check" >/dev/null +(cd "$check" && sha256sum -c MANIFEST.sha256 >/dev/null) +cmp "$check/fragment.yml" "$FRAGMENT/fragment.yml" +cosign verify "${repository}@${digest}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "^${SIGNER_WORKFLOW//./\\.}" \ + --certificate-github-workflow-repository "$SIGNED_REPOSITORY" >/dev/null +rm -rf "$check" + +echo "published ${repository}@${digest}" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "ref=${repository}@${digest}" + echo "digest=${digest}" + } >>"$GITHUB_OUTPUT" +fi diff --git a/actions/render-diff/action.yml b/actions/render-diff/action.yml new file mode 100644 index 0000000..55457fc --- /dev/null +++ b/actions/render-diff/action.yml @@ -0,0 +1,79 @@ +# Posts what a pull request's project-file change does to the Project's render, +# as one comment that is updated in place. +# +# It composes the estate twice, once with the base branch's project file and +# once with the pull request's, and diffs the Project's rendered artifact. Both +# compositions are `deploy-kit compose`; this action only swaps one fragment and +# diffs two directories. +# +# The estate's other inputs are handed in, already pulled, laid out as +# composition's own pull step leaves them +# (deploy-kit spec/v1/examples/workflows/compose.yml): +# +# /platform/ the Platform document's fragment, with its `ref` +# /fragments// every participant's fragment, with its `ref` +# /cluster-state.yml +# /held/ optional +# /pins.json optional +# /previous/lock.json optional, with previous/COMMIT +name: Render Diff +description: Compose the estate with a pull request's project file and comment the Project's render diff. + +inputs: + project-file: + description: The project file, relative to the repository root. The same path is read in both checkouts. + required: true + estate-inputs: + description: The directory holding the estate's pulled composition inputs. + required: true + base-directory: + description: A checkout of the pull request's base commit. + required: true + head-directory: + description: A checkout of the pull request's head commit. + required: false + default: "." + toolkit-directory: + description: The directory whose lockfile pins @jorisjonkers-dev/deploy-kit, already installed with npm ci. + required: false + default: "." + comment: + description: Post the diff on the pull request. Off, the diff is only written to the step summary. + required: false + default: "true" + github-token: + description: A token that may comment on the pull request. + required: false + default: ${{ github.token }} + +outputs: + changed: + description: Whether the Project's render differs between base and head. + value: ${{ steps.diff.outputs.changed }} + +runs: + using: composite + steps: + - id: diff + shell: bash + env: + PROJECT_FILE: ${{ inputs.project-file }} + ESTATE_INPUTS: ${{ inputs.estate-inputs }} + BASE_DIRECTORY: ${{ inputs.base-directory }} + HEAD_DIRECTORY: ${{ inputs.head-directory }} + TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + REPORT: ${{ runner.temp }}/render-diff.md + run: '"${GITHUB_ACTION_PATH}/run.sh"' + + - if: ${{ always() && inputs.comment == 'true' && github.event.pull_request.number }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + REPOSITORY: ${{ github.repository }} + PULL_REQUEST: ${{ github.event.pull_request.number }} + PROJECT: ${{ steps.diff.outputs.project }} + REPORT: ${{ runner.temp }}/render-diff.md + run: '"${GITHUB_ACTION_PATH}/comment.sh"' diff --git a/actions/render-diff/comment.sh b/actions/render-diff/comment.sh new file mode 100755 index 0000000..24eeb4e --- /dev/null +++ b/actions/render-diff/comment.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Post the report on the pull request, replacing this Project's earlier comment +# so a pull request carries one render diff per Project, not one per push. +set -euo pipefail + +: "${REPOSITORY:?}" "${PULL_REQUEST:?}" "${REPORT:?}" +[ -f "$REPORT" ] || { + echo "render-diff: no report was written; nothing to post" >&2 + exit 0 +} + +# The report's first line is its marker. +marker="$(head -n 1 "$REPORT")" +case "$marker" in + "") ;; + *) + echo "render-diff: the report carries no marker" >&2 + exit 1 + ;; +esac + +existing="$(gh api --paginate "repos/${REPOSITORY}/issues/${PULL_REQUEST}/comments" \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id" | head -n 1)" + +if [ -n "$existing" ]; then + gh api --method PATCH "repos/${REPOSITORY}/issues/comments/${existing}" -F "body=@${REPORT}" >/dev/null + echo "updated comment ${existing}" +else + gh api --method POST "repos/${REPOSITORY}/issues/${PULL_REQUEST}/comments" -F "body=@${REPORT}" >/dev/null + echo "posted a new comment" +fi diff --git a/actions/render-diff/run.sh b/actions/render-diff/run.sh new file mode 100755 index 0000000..03a09d4 --- /dev/null +++ b/actions/render-diff/run.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Compose the estate with the base project file and with the head one, and +# write the Project's render diff as Markdown. +# +# Exit 0 when head composes, whether or not the render changed. Exit 1 when +# head does not compose: the report then carries the diagnostics instead of a +# diff, because that is what the pull request would do to the estate. +set -euo pipefail + +: "${PROJECT_FILE:?}" "${ESTATE_INPUTS:?}" "${BASE_DIRECTORY:?}" "${REPORT:?}" "${REPOSITORY:?}" "${HEAD_SHA:?}" "${BASE_SHA:?}" +HEAD_DIRECTORY="${HEAD_DIRECTORY:-.}" +TOOLKIT_DIRECTORY="${TOOLKIT_DIRECTORY:-.}" +DEPLOY_KIT_COMMAND="${DEPLOY_KIT_COMMAND:-npx --no-install deploy-kit}" +# A comment holds 65536 characters; the diff gets most of them. +MAX_DIFF_BYTES="${MAX_DIFF_BYTES:-55000}" + +fail() { + echo "render-diff: $*" >&2 + exit 1 +} + +workspace="$PWD" +absolute() { + case "$1" in + /*) printf '%s' "$1" ;; + *) printf '%s/%s' "$workspace" "$1" ;; + esac +} + +deploy_kit() { + # shellcheck disable=SC2086 # a command and its fixed options, split on purpose + (cd "$TOOLKIT_DIRECTORY" && $DEPLOY_KIT_COMMAND "$@") +} + +estate="$(absolute "$ESTATE_INPUTS")" +[ -d "$estate/platform" ] || fail "${ESTATE_INPUTS}/platform is missing" +[ -d "$estate/fragments" ] || fail "${ESTATE_INPUTS}/fragments is missing" +[ -f "$estate/cluster-state.yml" ] || fail "${ESTATE_INPUTS}/cluster-state.yml is missing" +[ -f "$(absolute "$HEAD_DIRECTORY")/$PROJECT_FILE" ] || fail "no project file at ${HEAD_DIRECTORY}/${PROJECT_FILE}" + +# The integrity of the toolkit that composes, read from the lockfile that pins +# it, as composition itself records it. +integrity="${SCHEMA_PACKAGE_INTEGRITY:-}" +if [ -z "$integrity" ]; then + integrity="$(jq -r '.packages["node_modules/@jorisjonkers-dev/deploy-kit"].integrity // ""' "$TOOLKIT_DIRECTORY/package-lock.json")" +fi +[ -n "$integrity" ] || fail "${TOOLKIT_DIRECTORY}/package-lock.json pins no @jorisjonkers-dev/deploy-kit" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# compose : the estate with this side's project file in +# place of the Project's published fragment. A side with no project file, a +# Project the base branch does not have yet, composes the estate as pulled. +compose() { + local side="$1" checkout sha="$3" fragment project + checkout="$(absolute "$2")" + mkdir -p "$work/$side" + cp -R "$estate/fragments" "$work/$side/fragments" + + if [ -f "$checkout/$PROJECT_FILE" ]; then + fragment="$work/$side/packed" + deploy_kit publish "$checkout/$PROJECT_FILE" \ + --repository "$REPOSITORY" --source-sha "$sha" --version 0.0.0 --out "$fragment" \ + >"$work/$side/publish.log" 2>&1 || return 1 + project="$(yq '.spec.project' "$fragment/fragment.yml")" + rm -rf "$work/$side/fragments/$project" + mv "$fragment" "$work/$side/fragments/$project" + # A fragment is read beside the ref its pull resolved. This one was never + # pushed, so it is named by the commit it was packed from. + echo "ghcr.io/$(printf '%s' "${REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')/intent-${project}@sha256:$(printf '%s' "$sha" | sha256sum | cut -d' ' -f1)" \ + >"$work/$side/fragments/$project/ref" + echo "$project" >"$work/$side/project" + fi + + local options=(--platform "$estate/platform" --fragments "$work/$side/fragments" + --cluster-state "$estate/cluster-state.yml" --schema-package-integrity "$integrity" + --out "$work/$side/composed") + [ -d "$estate/held" ] && options+=(--held "$estate/held") + [ -f "$estate/pins.json" ] && options+=(--pins "$estate/pins.json") + if [ -f "$estate/previous/lock.json" ] && [ -f "$estate/previous/COMMIT" ]; then + options+=(--lock "$estate/previous/lock.json" --lock-commit "$(cat "$estate/previous/COMMIT")") + fi + deploy_kit compose "${options[@]}" >"$work/$side/compose.log" 2>&1 +} + +head_status=0 +compose head "$HEAD_DIRECTORY" "$HEAD_SHA" || head_status=$? +project="$(cat "$work/head/project" 2>/dev/null || true)" +[ -n "$project" ] || { + cat "$work/head/publish.log" >&2 || true + fail "the head project file could not be packed" +} +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "project=${project}" >>"$GITHUB_OUTPUT"; fi + +marker="" +{ + echo "$marker" + echo "### Render diff: \`${project}\`" + echo +} >"$REPORT" + +# Isolation composes a refused Project at its last fragment and still exits 0, +# so a refusal is read from what composition reports, not only its exit status. +isolated="" +if [ "$head_status" -eq 0 ] && [ -f "$work/head/composed/lock.json" ]; then + isolated="$(jq -r --arg p "$project" '.spec.isolated[$p] // "" | if type == "object" or type == "array" then tojson else . end' "$work/head/composed/lock.json")" +fi + +if [ "$head_status" -ne 0 ] || [ -n "$isolated" ]; then + { + echo "**This change does not compose.** Published as it is, composition would refuse \`${project}\` and keep it at its last composed release." + echo + echo '```' + if [ -n "$isolated" ]; then echo "$isolated"; fi + head -c "$MAX_DIFF_BYTES" "$work/head/compose.log" + echo '```' + } >>"$REPORT" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat "$REPORT" >>"$GITHUB_STEP_SUMMARY"; fi + if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "changed=true" >>"$GITHUB_OUTPUT"; fi + cat "$REPORT" + exit 1 +fi + +# The base side only has to give something to diff against. If the base does +# not compose, the whole head render is shown as new. +compose base "$BASE_DIRECTORY" "$BASE_SHA" || rm -rf "$work/base/composed" +mkdir -p "$work/base/composed/artifacts/$project" "$work/head/composed/artifacts/$project" + +changed=false +(cd "$work" && diff -ruN "base/composed/artifacts/$project" "head/composed/artifacts/$project" >"$work/render.diff") || changed=true +# Paths as a reviewer reads them, and no timestamps: the same change is the same comment. +sed -i.bak -E \ + -e "s#base/composed/artifacts/${project}/#a/#g" \ + -e "s#head/composed/artifacts/${project}/#b/#g" \ + -e 's#^(---|\+\+\+) ([^[:space:]]+)[[:space:]].*$#\1 \2#' \ + "$work/render.diff" + +if [ "$changed" = false ]; then + echo "No change to the rendered objects. The fragment still changes, so composition records a new release without moving the pin." >>"$REPORT" +else + files="$(grep -c '^diff -ruN ' "$work/render.diff" || true)" + { + echo "${files} rendered file(s) change. This is what Flux would apply once the release is published and composed." + echo + echo '```diff' + head -c "$MAX_DIFF_BYTES" "$work/render.diff" + if [ "$(wc -c <"$work/render.diff")" -gt "$MAX_DIFF_BYTES" ]; then + echo + echo "... diff cut at ${MAX_DIFF_BYTES} bytes; run the composition locally for the rest" + fi + echo '```' + } >>"$REPORT" +fi + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat "$REPORT" >>"$GITHUB_STEP_SUMMARY"; fi +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "changed=${changed}" >>"$GITHUB_OUTPUT"; fi +cat "$REPORT" diff --git a/release-please-config.json b/release-please-config.json index ec6f3b7..fabf9c5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -61,6 +61,10 @@ { "type": "generic", "path": ".github/workflows/publish-api-clients.yml" + }, + { + "type": "generic", + "path": ".github/workflows/publish-fragment.yml" } ] } diff --git a/tests/fixtures/estate-delivery/deploy-kit b/tests/fixtures/estate-delivery/deploy-kit new file mode 100755 index 0000000..30206e7 --- /dev/null +++ b/tests/fixtures/estate-delivery/deploy-kit @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# A stand-in for the deploy-kit command: it records how it was called and +# writes files of the shapes the real command writes, so the scripts around it +# can be tested where the real one is not installed. +set -euo pipefail +echo "$*" >>"${STUB_LOG:-/dev/null}" + +command="${1:-}" +shift || true +case "$command" in + validate) + if [ -n "${STUB_REFUSE_VALIDATE:-}" ]; then + echo "E_STUB refused" >&2 + exit 1 + fi + echo "accepted ($# read)" + ;; + publish) + file="$1" + shift + while [ $# -gt 0 ]; do + case "$1" in + --out) out="$2" ;; + --version) version="$2" ;; + esac + shift 2 + done + if [ -n "${STUB_PACK_NOTHING:-}" ]; then exit 0; fi + mkdir -p "$out" + cp "$file" "$out/" + project="$(sed -n 's/^project: *\([A-Za-z0-9_-]*\).*/\1/p' "$file")" + printf 'spec:\n project: %s\n version: %s\n inputsSha: abc123\n' "$project" "$version" >"$out/fragment.yml" + echo "$project $version packed in $out" + ;; + compose) + while [ $# -gt 0 ]; do + case "$1" in + --fragments) fragments="$2" ;; + --out) out="$2" ;; + esac + shift 2 + done + isolated='{}' + for fragment in "$fragments"/*/; do + project="$(basename "$fragment")" + mkdir -p "$out/artifacts/$project" + # The render of a project is its project file's `memory:` lines. + grep -h 'memory:' "$fragment"/*.project.yml >"$out/artifacts/$project/workload.yaml" || true + if grep -q 'REFUSE' "$fragment"/*.project.yml; then + isolated="{\"$project\":{\"codes\":[\"E_STUB\"]}}" + fi + if grep -q 'CRASH' "$fragment"/*.project.yml; then + echo "E_PLATFORM the Platform document is refused" >&2 + exit 1 + fi + done + printf '{"spec":{"isolated":%s}}\n' "$isolated" >"$out/lock.json" + echo "composed" + ;; + *) + echo "usage" >&2 + exit 2 + ;; +esac diff --git a/tests/test_publish_fragment.py b/tests/test_publish_fragment.py new file mode 100644 index 0000000..99a3174 --- /dev/null +++ b/tests/test_publish_fragment.py @@ -0,0 +1,257 @@ +"""The publish-fragment workflow and the scripts it runs. + +The scripts decide nothing: they hand the deploy-kit command its arguments and +move bytes. So they are tested with a stand-in command that records its calls +(tests/fixtures/estate-delivery/deploy-kit), and stand-ins for oras and cosign. +""" +from __future__ import annotations + +import os +import re +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / "actions" / "publish-fragment" +WORKFLOW = ROOT / ".github" / "workflows" / "publish-fragment.yml" +STUB = ROOT / "tests" / "fixtures" / "estate-delivery" / "deploy-kit" +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def executable(path: Path, body: str) -> Path: + path.write_text("#!/usr/bin/env bash\n" + body) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + return path + + +class Pack(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()).resolve() + (self.dir / "deploy" / "env").mkdir(parents=True) + (self.dir / "deploy" / "notes.project.yml").write_text("project: notes\n") + (self.dir / "deploy" / "env" / "base.env").write_text("A=1\n") + self.log = self.dir / "calls.log" + + def run_pack(self, **overrides): + env = { + **os.environ, + "PROJECT_FILE": "deploy/notes.project.yml", + "VERSION": "v1.4.0", + "SOURCE_SHA": SHA, + "REPOSITORY": "JorisJonkers-dev/notes", + "OUT": "fragment", + "DEPLOY_KIT_COMMAND": str(STUB), + "STUB_LOG": str(self.log), + "GITHUB_OUTPUT": str(self.dir / "output"), + **overrides, + } + return subprocess.run( + ["bash", str(ACTION / "pack.sh")], cwd=self.dir, env=env, capture_output=True, text=True + ) + + def test_validates_then_packs_the_release_and_writes_a_manifest(self): + run = self.run_pack(VALIDATE_WITH="deploy/env") + self.assertEqual(run.returncode, 0, run.stderr) + + calls = self.log.read_text().splitlines() + project = self.dir.resolve() / "deploy" / "notes.project.yml" + self.assertEqual(calls[0], f"validate {self.dir}/deploy/notes.project.yml {self.dir}/deploy/env") + self.assertEqual( + calls[1], + f"publish {self.dir}/deploy/notes.project.yml --repository JorisJonkers-dev/notes " + f"--source-sha {SHA} --version 1.4.0 --out {self.dir}/fragment", + ) + self.assertTrue(project.exists()) + + fragment = self.dir / "fragment" + check = subprocess.run( + ["sha256sum", "-c", "MANIFEST.sha256"], cwd=fragment, capture_output=True, text=True + ) + self.assertEqual(check.returncode, 0, check.stdout + check.stderr) + manifest = (fragment / "MANIFEST.sha256").read_text() + self.assertIn("./fragment.yml", manifest) + self.assertNotIn("MANIFEST.sha256", manifest) + + self.assertEqual( + (self.dir / "output").read_text(), "project=notes\nversion=1.4.0\ninputs-sha=abc123\n" + ) + + def test_a_refused_project_file_packs_nothing(self): + run = self.run_pack(STUB_REFUSE_VALIDATE="1") + self.assertEqual(run.returncode, 1) + self.assertEqual(self.log.read_text().count("publish"), 0) + self.assertFalse((self.dir / "fragment").exists()) + + def test_what_is_not_a_release_is_refused_before_the_command_runs(self): + cases = { + "a branch name": {"VERSION": "main"}, + "a pre-release": {"VERSION": "v1.4.0-rc.1"}, + "a short commit": {"SOURCE_SHA": "0123abc"}, + "a missing project file": {"PROJECT_FILE": "deploy/other.project.yml"}, + "a path to read that is not there": {"VALIDATE_WITH": "deploy/gone"}, + } + for name, overrides in cases.items(): + with self.subTest(name): + self.log.write_text("") + run = self.run_pack(**overrides) + self.assertEqual(run.returncode, 1) + self.assertIn("publish-fragment:", run.stderr) + self.assertEqual(self.log.read_text(), "") + + def test_a_command_that_packs_nothing_fails(self): + run = self.run_pack(STUB_PACK_NOTHING="1") + self.assertEqual(run.returncode, 1) + self.assertIn("packed no fragment.yml", run.stderr) + + +class Push(unittest.TestCase): + """push.sh against stand-ins for oras and cosign that record their calls.""" + + def setUp(self): + self.dir = Path(tempfile.mkdtemp()).resolve() + self.bin = self.dir / "bin" + self.bin.mkdir() + self.log = self.dir / "calls.log" + self.fragment = self.dir / "fragment" + self.fragment.mkdir() + (self.fragment / "fragment.yml").write_text("spec:\n project: notes\n inputsSha: abc123\n") + subprocess.run( + "sha256sum ./fragment.yml > MANIFEST.sha256", shell=True, cwd=self.fragment, check=True + ) + executable( + self.bin / "oras", + 'echo "oras $*" >>"$CALLS"\n' + 'case "$1 $2" in\n' + ' "manifest fetch") [ -n "${LATEST:-}" ] || exit 1\n' + ' printf \'{"annotations":{"org.opencontainers.image.version":"%s"}}\' "$LATEST" ;;\n' + ' "push "*) printf \'%s\' "${DIGEST-sha256:feed}" ;;\n' + ' "pull "*) cp -R "${PULLED:-$FRAGMENT}/." "$4" ;;\n' + "esac\n", + ) + executable(self.bin / "cosign", 'echo "cosign $*" >>"$CALLS"\n[ "$1" != "${COSIGN_FAILS:-}" ]\n') + + def run_push(self, **overrides): + env = { + **os.environ, + "PATH": f"{self.bin}:{os.environ['PATH']}", + "CALLS": str(self.log), + "FRAGMENT": str(self.fragment), + "PROJECT": "notes", + "VERSION": "1.4.0", + "SOURCE_SHA": SHA, + "OWNER": "JorisJonkers-dev", + "SIGNED_REPOSITORY": "JorisJonkers-dev/notes", + "GITHUB_OUTPUT": str(self.dir / "output"), + **overrides, + } + return subprocess.run(["bash", str(ACTION / "push.sh")], env=env, capture_output=True, text=True) + + def calls(self, tool): + return [line for line in self.log.read_text().splitlines() if line.startswith(tool + " ")] + + def test_a_first_publish_is_tagged_latest_signed_and_read_back(self): + run = self.run_push() + self.assertEqual(run.returncode, 0, run.stderr) + + push = next(c for c in self.calls("oras") if c.startswith("oras push")) + self.assertIn("ghcr.io/jorisjonkers-dev/intent-notes:1.4.0,latest", push) + self.assertIn("org.opencontainers.image.version=1.4.0", push) + self.assertIn(f"org.opencontainers.image.revision={SHA}", push) + self.assertIn("dev.jorisjonkers.inputs-sha=abc123", push) + + sign, verify = self.calls("cosign") + self.assertEqual(sign, "cosign sign --yes ghcr.io/jorisjonkers-dev/intent-notes@sha256:feed") + self.assertIn("verify ghcr.io/jorisjonkers-dev/intent-notes@sha256:feed", verify) + self.assertIn("--certificate-oidc-issuer https://token.actions.githubusercontent.com", verify) + self.assertIn( + r"--certificate-identity-regexp ^https://github\.com/JorisJonkers-dev/github-workflows/" + r"\.github/workflows/publish-fragment\.yml@", + verify, + ) + self.assertIn("--certificate-github-workflow-repository JorisJonkers-dev/notes", verify) + + self.assertEqual( + (self.dir / "output").read_text(), + "ref=ghcr.io/jorisjonkers-dev/intent-notes@sha256:feed\ndigest=sha256:feed\n", + ) + + def test_latest_only_moves_forward(self): + for latest, expected in {"1.3.9": "1.4.0,latest", "1.4.0": "1.4.0,latest", "1.10.0": "1.4.0 ", "2.0.0": "1.4.0 "}.items(): + with self.subTest(latest): + self.log.write_text("") + run = self.run_push(LATEST=latest) + self.assertEqual(run.returncode, 0, run.stderr) + push = next(c for c in self.calls("oras") if c.startswith("oras push")) + self.assertIn(f"intent-notes:{expected}", push + " ") + + def test_nothing_is_reported_published_unless_it_reads_back_and_verifies(self): + other = self.dir / "other" + other.mkdir() + (other / "fragment.yml").write_text("spec:\n project: someone-else\n") + subprocess.run("sha256sum ./fragment.yml > MANIFEST.sha256", shell=True, cwd=other, check=True) + tampered = self.dir / "tampered" + tampered.mkdir() + (tampered / "fragment.yml").write_text("changed\n") + (tampered / "MANIFEST.sha256").write_text((self.fragment / "MANIFEST.sha256").read_text()) + + cases = { + "a push that returns no digest": {"DIGEST": ""}, + "a signature that fails": {"COSIGN_FAILS": "sign"}, + "a pulled package that is another fragment": {"PULLED": str(other)}, + "a pulled file that does not match its manifest": {"PULLED": str(tampered)}, + "a signature that does not verify": {"COSIGN_FAILS": "verify"}, + } + for name, overrides in cases.items(): + with self.subTest(name): + output = self.dir / "output" + output.unlink(missing_ok=True) + run = self.run_push(**overrides) + self.assertNotEqual(run.returncode, 0) + self.assertFalse(output.exists() and output.read_text()) + + +class Workflow(unittest.TestCase): + def setUp(self): + self.text = WORKFLOW.read_text() + + def test_no_caller_input_is_spliced_into_a_script(self): + # An input reaches a script through `env`, never by expansion inside `run`, + # where a caller's value would be run as shell. + in_run = False + for line in self.text.splitlines(): + stripped = line.strip() + if re.match(r"^(- )?run: ", stripped) or stripped == "run: |": + in_run = True + indent = len(line) - len(line.lstrip()) + if "${{" in stripped: + self.fail(f"expression in a run line: {stripped}") + continue + if in_run: + if stripped and len(line) - len(line.lstrip()) <= indent: + in_run = False + elif "${{" in line: + self.fail(f"expression inside a script: {stripped}") + + def test_it_holds_no_permission_until_the_job_asks(self): + self.assertIn("\npermissions: {}\n", self.text) + job = self.text.split("jobs:\n", 1)[1] + self.assertIn("contents: read", job) + self.assertIn("packages: write", job) + self.assertIn("id-token: write", job) + self.assertNotIn("contents: write", job) + + 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_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) + self.assertIn('gh workflow run compose.yml --repo "$ESTATE" --ref main', self.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_render_diff.py b/tests/test_render_diff.py new file mode 100644 index 0000000..f3dc540 --- /dev/null +++ b/tests/test_render_diff.py @@ -0,0 +1,222 @@ +"""The render-diff action: compose base and head, diff one Project's render, comment once. + +Run against a stand-in deploy-kit command (tests/fixtures/estate-delivery/deploy-kit) whose +render of a project is its project file's `memory:` lines, and a stand-in gh. +""" +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / "actions" / "render-diff" +STUB = ROOT / "tests" / "fixtures" / "estate-delivery" / "deploy-kit" + + +def project_file(directory: Path, body: str) -> None: + (directory / "deploy").mkdir(parents=True, exist_ok=True) + (directory / "deploy" / "notes.project.yml").write_text("project: notes\n" + body) + + +class RenderDiff(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()).resolve() + estate = self.dir / "estate" + (estate / "platform").mkdir(parents=True) + (estate / "fragments" / "data").mkdir(parents=True) + (estate / "fragments" / "data" / "data.project.yml").write_text("project: data\nmemory: 1Gi\n") + (estate / "cluster-state.yml").write_text("bindings: []\n") + project_file(self.dir / "base", "memory: 128Mi\n") + project_file(self.dir / "head", "memory: 256Mi\n") + self.report = self.dir / "report.md" + self.log = self.dir / "calls.log" + + def run_diff(self, base="base", head="head", **overrides): + env = { + **os.environ, + "PROJECT_FILE": "deploy/notes.project.yml", + "ESTATE_INPUTS": "estate", + "BASE_DIRECTORY": base, + "HEAD_DIRECTORY": head, + "REPOSITORY": "JorisJonkers-dev/notes", + "HEAD_SHA": "b" * 40, + "BASE_SHA": "a" * 40, + "REPORT": str(self.report), + "DEPLOY_KIT_COMMAND": str(STUB), + "SCHEMA_PACKAGE_INTEGRITY": "sha512-test", + "STUB_LOG": str(self.log), + "GITHUB_OUTPUT": str(self.dir / "output"), + **overrides, + } + return subprocess.run(["bash", str(ACTION / "run.sh")], cwd=self.dir, env=env, capture_output=True, text=True) + + def test_a_changed_render_is_reported_as_a_diff_of_the_projects_files_only(self): + run = self.run_diff() + self.assertEqual(run.returncode, 0, run.stderr) + report = self.report.read_text() + + self.assertTrue(report.startswith("\n")) + self.assertIn("1 rendered file(s) change", report) + self.assertIn("--- a/workload.yaml\n+++ b/workload.yaml\n", report) + self.assertIn("-memory: 128Mi\n+memory: 256Mi\n", report) + # Another Project's render is in both compositions and in neither diff. + self.assertNotIn("1Gi", report) + self.assertIn("project=notes\n", (self.dir / "output").read_text()) + self.assertIn("changed=true\n", (self.dir / "output").read_text()) + + def test_the_same_change_writes_the_same_report(self): + self.assertEqual(self.run_diff().returncode, 0) + first = self.report.read_text() + self.assertEqual(self.run_diff().returncode, 0) + self.assertEqual(self.report.read_text(), first) + + def test_both_sides_are_composed_with_the_estate_and_the_pinned_integrity(self): + self.assertEqual(self.run_diff().returncode, 0) + composes = [line for line in self.log.read_text().splitlines() if line.startswith("compose ")] + self.assertEqual(len(composes), 2) + for call in composes: + self.assertIn(f"--platform {self.dir}/estate/platform", call) + self.assertIn(f"--cluster-state {self.dir}/estate/cluster-state.yml", call) + self.assertIn("--schema-package-integrity sha512-test", call) + self.assertNotIn("--held", call) + self.assertNotIn("--lock", call) + + def test_optional_estate_inputs_are_passed_when_present(self): + estate = self.dir / "estate" + (estate / "held").mkdir() + (estate / "pins.json").write_text("{}") + (estate / "previous").mkdir() + (estate / "previous" / "lock.json").write_text("{}") + (estate / "previous" / "COMMIT").write_text("c" * 40 + "\n") + self.assertEqual(self.run_diff().returncode, 0) + call = next(line for line in self.log.read_text().splitlines() if line.startswith("compose ")) + self.assertIn(f"--held {estate}/held", call) + self.assertIn(f"--pins {estate}/pins.json", call) + self.assertIn(f"--lock {estate}/previous/lock.json --lock-commit {'c' * 40}", call) + + def test_an_unchanged_render_says_so(self): + project_file(self.dir / "head", "memory: 128Mi\n# a comment only\n") + run = self.run_diff() + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("No change to the rendered objects.", self.report.read_text()) + self.assertIn("changed=false\n", (self.dir / "output").read_text()) + + def test_a_project_new_to_the_base_branch_is_all_additions(self): + (self.dir / "empty").mkdir() + run = self.run_diff(base="empty") + self.assertEqual(run.returncode, 0, run.stderr) + report = self.report.read_text() + self.assertIn("+memory: 256Mi\n", report) + self.assertNotIn("-memory", report) + + def test_a_head_composition_isolates_is_a_failure_with_its_codes(self): + project_file(self.dir / "head", "memory: 256Mi\n# REFUSE\n") + run = self.run_diff() + self.assertEqual(run.returncode, 1) + report = self.report.read_text() + self.assertIn("**This change does not compose.**", report) + self.assertIn("E_STUB", report) + self.assertNotIn("```diff", report) + + def test_a_head_composition_refuses_outright_is_a_failure_with_its_output(self): + project_file(self.dir / "head", "memory: 256Mi\n# CRASH\n") + run = self.run_diff() + self.assertEqual(run.returncode, 1) + self.assertIn("E_PLATFORM the Platform document is refused", self.report.read_text()) + + def test_a_long_diff_is_cut_and_says_so(self): + project_file(self.dir / "head", "".join(f"memory: {n}Mi\n" for n in range(400))) + run = self.run_diff(MAX_DIFF_BYTES="300") + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("... diff cut at 300 bytes", self.report.read_text()) + self.assertLess(len(self.report.read_text()), 1000) + + def test_missing_inputs_are_named(self): + cases = { + "no platform": lambda: (self.dir / "estate" / "platform").rmdir(), + "no cluster state": lambda: (self.dir / "estate" / "cluster-state.yml").unlink(), + "no head project file": lambda: (self.dir / "head" / "deploy" / "notes.project.yml").unlink(), + } + for name, remove in cases.items(): + with self.subTest(name): + self.setUp() + remove() + run = self.run_diff() + self.assertEqual(run.returncode, 1) + self.assertIn("render-diff:", run.stderr) + + def test_a_lockfile_that_pins_no_toolkit_is_refused(self): + (self.dir / "package-lock.json").write_text('{"packages": {}}') + run = self.run_diff(SCHEMA_PACKAGE_INTEGRITY="") + self.assertEqual(run.returncode, 1) + self.assertIn("pins no @jorisjonkers-dev/deploy-kit", run.stderr) + + (self.dir / "package-lock.json").write_text( + '{"packages": {"node_modules/@jorisjonkers-dev/deploy-kit": {"integrity": "sha512-pinned"}}}' + ) + self.assertEqual(self.run_diff(SCHEMA_PACKAGE_INTEGRITY="").returncode, 0) + self.assertIn("--schema-package-integrity sha512-pinned", self.log.read_text()) + + +class Comment(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()).resolve() + self.report = self.dir / "report.md" + self.report.write_text("\n### Render diff: `notes`\n") + self.log = self.dir / "gh.log" + gh = self.dir / "gh" + gh.write_text( + "#!/usr/bin/env bash\n" + 'echo "$*" >>"$GH_LOG"\n' + 'case "$*" in *--paginate*) printf \'%s\' "${EXISTING:-}" ;; esac\n' + ) + gh.chmod(gh.stat().st_mode | stat.S_IEXEC) + + def run_comment(self, **overrides): + env = { + **os.environ, + "PATH": f"{self.dir}:{os.environ['PATH']}", + "GH_LOG": str(self.log), + "REPOSITORY": "JorisJonkers-dev/notes", + "PULL_REQUEST": "12", + "REPORT": str(self.report), + **overrides, + } + return subprocess.run(["bash", str(ACTION / "comment.sh")], env=env, capture_output=True, text=True) + + def test_the_first_report_is_a_new_comment(self): + run = self.run_comment() + self.assertEqual(run.returncode, 0, run.stderr) + calls = self.log.read_text().splitlines() + self.assertIn('startswith("")', calls[0]) + self.assertEqual( + calls[1], f"api --method POST repos/JorisJonkers-dev/notes/issues/12/comments -F body=@{self.report}" + ) + + def test_a_later_report_replaces_the_projects_comment(self): + run = self.run_comment(EXISTING="991\n") + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual( + self.log.read_text().splitlines()[1], + f"api --method PATCH repos/JorisJonkers-dev/notes/issues/comments/991 -F body=@{self.report}", + ) + + def test_a_report_without_its_marker_is_not_posted(self): + self.report.write_text("### Render diff\n") + run = self.run_comment() + self.assertEqual(run.returncode, 1) + self.assertFalse(self.log.exists()) + + def test_no_report_posts_nothing(self): + self.report.unlink() + run = self.run_comment() + self.assertEqual(run.returncode, 0) + self.assertFalse(self.log.exists()) + + +if __name__ == "__main__": + unittest.main() From d49dc0830cf35c383e3e96f08967d6d1a5b16ef8 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 3 Oct 2026 13:30:22 +0200 Subject: [PATCH 2/3] fix: hold a pull request's project name and render to their shape, and fail closed when latest cannot be read --- actions/publish-fragment/pack.sh | 3 ++ actions/publish-fragment/push.sh | 20 +++++++++-- actions/render-diff/comment.sh | 28 +++++++++------ actions/render-diff/run.sh | 42 +++++++++++++++++----- tests/test_publish_fragment.py | 28 ++++++++++++++- tests/test_render_diff.py | 61 ++++++++++++++++++++++++++++---- 6 files changed, 153 insertions(+), 29 deletions(-) diff --git a/actions/publish-fragment/pack.sh b/actions/publish-fragment/pack.sh index ec22ed4..98a9e82 100755 --- a/actions/publish-fragment/pack.sh +++ b/actions/publish-fragment/pack.sh @@ -41,10 +41,13 @@ version="${VERSION#v}" # The project file and what is read with it: env files, Assets. A directory is # read as every file below it. read_together=("$(absolute "$PROJECT_FILE")") +# Split on spaces, and never expanded as a pattern. +set -f for path in $VALIDATE_WITH; do [ -e "$path" ] || fail "validate-with names ${path}, which does not exist" read_together+=("$(absolute "$path")") done +set +f echo "::group::Validate" deploy_kit validate "${read_together[@]}" diff --git a/actions/publish-fragment/push.sh b/actions/publish-fragment/push.sh index 7b9e2c6..2fe9e3d 100755 --- a/actions/publish-fragment/push.sh +++ b/actions/publish-fragment/push.sh @@ -18,9 +18,23 @@ fail() { repository="ghcr.io/$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')/intent-${PROJECT}" inputs_sha="$(yq '.spec.inputsSha' "$FRAGMENT/fragment.yml")" -# The release `latest` names now, if any. Absent on a Project's first publish. -current="$(oras manifest fetch "${repository}:latest" 2>/dev/null | - jq -r '.annotations["org.opencontainers.image.version"] // ""' || true)" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version '${VERSION}' is not a release" +[[ "$PROJECT" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "project '${PROJECT}' is not a name" + +# The release `latest` names now. Only a registry that says there is no such +# manifest means a first publish. Any other failure to read it stops here: an +# answer that could not be read is not "nothing published yet", and treating +# it so would let a re-run of an old release move `latest` back. +current="" +if manifest="$(oras manifest fetch "${repository}:latest" 2>"${TMPDIR:-/tmp}/latest.err")"; then + current="$(jq -r '.annotations["org.opencontainers.image.version"] // ""' <<<"$manifest")" + [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + fail "${repository}:latest carries no release version, so it cannot be compared with ${VERSION}" +elif ! grep -qiE 'not found|name unknown|manifest unknown' "${TMPDIR:-/tmp}/latest.err"; then + cat "${TMPDIR:-/tmp}/latest.err" >&2 + fail "could not read ${repository}:latest" +fi + tags="$VERSION" if [ -z "$current" ] || [ "$(printf '%s\n%s\n' "$current" "$VERSION" | sort -V | tail -n 1)" = "$VERSION" ]; then tags="${VERSION},latest" diff --git a/actions/render-diff/comment.sh b/actions/render-diff/comment.sh index 24eeb4e..9a02dc6 100755 --- a/actions/render-diff/comment.sh +++ b/actions/render-diff/comment.sh @@ -9,18 +9,26 @@ set -euo pipefail exit 0 } -# The report's first line is its marker. +# The report's first line is its marker. It names a Project, and nothing else: +# it selects which comment is replaced, so it is never taken as written. marker="$(head -n 1 "$REPORT")" -case "$marker" in - "") ;; - *) - echo "render-diff: the report carries no marker" >&2 - exit 1 - ;; -esac +[[ "$marker" =~ ^\")', calls[0]) + self.assertEqual(calls[0], "api --paginate repos/JorisJonkers-dev/notes/issues/12/comments") self.assertEqual( calls[1], f"api --method POST repos/JorisJonkers-dev/notes/issues/12/comments -F body=@{self.report}" ) def test_a_later_report_replaces_the_projects_comment(self): - run = self.run_comment(EXISTING="991\n") + comments = [ + {"id": 7, "body": "I quote it: \nmine"}, + {"id": 8, "body": "\nanother Project's"}, + {"id": 991, "body": "\n### Render diff"}, + ] + run = self.run_comment(EXISTING=json.dumps(comments)) self.assertEqual(run.returncode, 0, run.stderr) self.assertEqual( self.log.read_text().splitlines()[1], f"api --method PATCH repos/JorisJonkers-dev/notes/issues/comments/991 -F body=@{self.report}", ) - def test_a_report_without_its_marker_is_not_posted(self): - self.report.write_text("### Render diff\n") - run = self.run_comment() + def test_a_report_without_a_plain_marker_is_not_posted(self): + for first_line in ( + "### Render diff", + '', + "", + "", + ): + with self.subTest(first_line): + self.report.write_text(first_line + "\nbody\n") + run = self.run_comment() + self.assertEqual(run.returncode, 1) + self.assertFalse(self.log.exists()) + + def test_a_pull_request_that_is_not_a_number_is_refused(self): + run = self.run_comment(PULL_REQUEST="12/../../issues/3") self.assertEqual(run.returncode, 1) self.assertFalse(self.log.exists()) From 443783d0fa8e58ddb1994ba621255873a57912ff Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sat, 3 Oct 2026 13:36:00 +0200 Subject: [PATCH 3/3] fix: replace only a render-diff comment this action's own identity wrote --- actions/render-diff/action.yml | 6 +++++- actions/render-diff/comment.sh | 10 +++++++--- actions/render-diff/run.sh | 2 +- tests/test_render_diff.py | 19 ++++++++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/actions/render-diff/action.yml b/actions/render-diff/action.yml index 55457fc..98cbc91 100644 --- a/actions/render-diff/action.yml +++ b/actions/render-diff/action.yml @@ -45,6 +45,10 @@ inputs: description: A token that may comment on the pull request. required: false default: ${{ github.token }} + comment-author: + description: The login the token comments as. Only that login's earlier comment is replaced. + required: false + default: github-actions[bot] outputs: changed: @@ -74,6 +78,6 @@ runs: GH_TOKEN: ${{ inputs.github-token }} REPOSITORY: ${{ github.repository }} PULL_REQUEST: ${{ github.event.pull_request.number }} - PROJECT: ${{ steps.diff.outputs.project }} + COMMENT_AUTHOR: ${{ inputs.comment-author }} REPORT: ${{ runner.temp }}/render-diff.md run: '"${GITHUB_ACTION_PATH}/comment.sh"' diff --git a/actions/render-diff/comment.sh b/actions/render-diff/comment.sh index 9a02dc6..3f8acde 100755 --- a/actions/render-diff/comment.sh +++ b/actions/render-diff/comment.sh @@ -21,10 +21,14 @@ marker="$(head -n 1 "$REPORT")" exit 1 } -# Only a comment this action wrote is ever replaced: the marker is handed to jq -# as a value, and the comment must open with it. +# Only a comment this action wrote is ever replaced. The marker alone does not +# say that: anyone can open a comment with it, and a token that may comment may +# also edit theirs. So the comment must open with the marker AND be written by +# the identity this token comments as. +AUTHOR="${COMMENT_AUTHOR:-github-actions[bot]}" existing="$(gh api --paginate "repos/${REPOSITORY}/issues/${PULL_REQUEST}/comments" | - jq -r --arg marker "$marker" '.[] | select(.body | startswith($marker + "\n")) | .id' | head -n 1)" + jq -r --arg marker "$marker" --arg author "$AUTHOR" \ + '.[] | select(.user.login == $author and (.body | startswith($marker + "\n"))) | .id' | head -n 1)" [[ "$existing" =~ ^[0-9]*$ ]] || { echo "render-diff: the comment listing returned an id that is not one" >&2 exit 1 diff --git a/actions/render-diff/run.sh b/actions/render-diff/run.sh index 3d8e0cc..0403a1e 100755 --- a/actions/render-diff/run.sh +++ b/actions/render-diff/run.sh @@ -24,7 +24,7 @@ MAX_DIFF_BYTES="${MAX_DIFF_BYTES:-55000}" # the block and continue as Markdown in a comment this action posts. fenced() { local language="$1" file="$2" longest fence - longest="$({ grep -o '`\{3,\}' "$file" || true; } | awk '{ if (length($0) > n) n = length($0) } END { print n + 0 }')" + longest="$({ grep -a -o '`\{3,\}' "$file" || true; } | awk '{ if (length($0) > n) n = length($0) } END { print n + 0 }')" fence="$(printf '%*s' "$((longest > 2 ? longest + 1 : 3))" '' | tr ' ' '`')" printf '%s%s\n' "$fence" "$language" cat "$file" diff --git a/tests/test_render_diff.py b/tests/test_render_diff.py index e43615c..5321176 100644 --- a/tests/test_render_diff.py +++ b/tests/test_render_diff.py @@ -231,9 +231,10 @@ def test_the_first_report_is_a_new_comment(self): def test_a_later_report_replaces_the_projects_comment(self): comments = [ - {"id": 7, "body": "I quote it: \nmine"}, - {"id": 8, "body": "\nanother Project's"}, - {"id": 991, "body": "\n### Render diff"}, + {"id": 6, "user": {"login": "someone"}, "body": "\nopened with the marker by hand"}, + {"id": 7, "user": {"login": "github-actions[bot]"}, "body": "I quote it: \nmine"}, + {"id": 8, "user": {"login": "github-actions[bot]"}, "body": "\nanother Project's"}, + {"id": 991, "user": {"login": "github-actions[bot]"}, "body": "\n### Render diff"}, ] run = self.run_comment(EXISTING=json.dumps(comments)) self.assertEqual(run.returncode, 0, run.stderr) @@ -242,6 +243,18 @@ def test_a_later_report_replaces_the_projects_comment(self): f"api --method PATCH repos/JorisJonkers-dev/notes/issues/comments/991 -F body=@{self.report}", ) + def test_someone_elses_comment_is_never_replaced(self): + theirs = [{"id": 6, "user": {"login": "someone"}, "body": "\nby hand"}] + run = self.run_comment(EXISTING=json.dumps(theirs)) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("--method POST", self.log.read_text().splitlines()[1]) + + mine = [{"id": 44, "user": {"login": "estate-bot[bot]"}, "body": "\nearlier"}] + self.log.unlink() + run = self.run_comment(EXISTING=json.dumps(mine), COMMENT_AUTHOR="estate-bot[bot]") + self.assertEqual(run.returncode, 0, run.stderr) + self.assertIn("--method PATCH repos/JorisJonkers-dev/notes/issues/comments/44", self.log.read_text()) + def test_a_report_without_a_plain_marker_is_not_posted(self): for first_line in ( "### Render diff",