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..98a9e82 --- /dev/null +++ b/actions/publish-fragment/pack.sh @@ -0,0 +1,83 @@ +#!/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")") +# 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[@]}" +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..2fe9e3d --- /dev/null +++ b/actions/publish-fragment/push.sh @@ -0,0 +1,80 @@ +#!/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")" + +[[ "$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" +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..98cbc91 --- /dev/null +++ b/actions/render-diff/action.yml @@ -0,0 +1,83 @@ +# 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 }} + 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: + 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 }} + 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 new file mode 100755 index 0000000..3f8acde --- /dev/null +++ b/actions/render-diff/comment.sh @@ -0,0 +1,43 @@ +#!/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. 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")" +[[ "$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 + { + if [ -n "$isolated" ]; then echo "$isolated"; fi + head -c "$MAX_DIFF_BYTES" "$work/head/compose.log" + } >"$work/refusal.txt" + fenced "" "$work/refusal.txt" + } >>"$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 + head -c "$MAX_DIFF_BYTES" "$work/render.diff" >"$work/shown.diff" + if [ "$(wc -c <"$work/render.diff")" -gt "$MAX_DIFF_BYTES" ]; then + printf '\n... diff cut at %s bytes; run the composition locally for the rest\n' "$MAX_DIFF_BYTES" >>"$work/shown.diff" + fi + fenced diff "$work/shown.diff" + } >>"$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..127caf6 --- /dev/null +++ b/tests/test_publish_fragment.py @@ -0,0 +1,283 @@ +"""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' + ' if [ -n "${LATEST_ERROR:-}" ]; then echo "$LATEST_ERROR" >&2; exit 1; fi\n' + ' if [ -z "${LATEST:-}" ]; then echo "Error: ghcr.io/x:latest: not found" >&2; exit 1; fi\n' + ' if [ "$LATEST" = unlabelled ]; then echo "{}"; exit 0; fi\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_a_latest_that_cannot_be_read_is_not_taken_for_a_first_publish(self): + cases = { + "a registry that does not answer": {"LATEST_ERROR": "Error: dial tcp: i/o timeout"}, + "a registry that refuses the token": {"LATEST_ERROR": "Error: unauthorized: authentication required"}, + "a latest with no release on it": {"LATEST": "unlabelled"}, + } + for name, overrides in cases.items(): + with self.subTest(name): + self.log.write_text("") + run = self.run_push(**overrides) + self.assertEqual(run.returncode, 1) + self.assertIn("publish-fragment:", run.stderr) + self.assertFalse([c for c in self.calls("oras") if c.startswith("oras push")]) + self.assertFalse(self.calls("cosign")) + + def test_what_is_not_a_release_or_a_name_is_never_pushed(self): + for overrides in ({"VERSION": "1.4.0,latest"}, {"VERSION": "latest"}, {"PROJECT": "../other"}, {"PROJECT": "Notes"}): + with self.subTest(str(overrides)): + self.log.write_text("") + run = self.run_push(**overrides) + self.assertEqual(run.returncode, 1) + self.assertEqual(self.log.read_text(), "") + + 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..5321176 --- /dev/null +++ b/tests/test_render_diff.py @@ -0,0 +1,284 @@ +"""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 json +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_what_a_pull_request_renders_cannot_close_the_code_block(self): + project_file(self.dir / "head", "memory: 256Mi\nmemory: ```\nmemory: `````\n@someone look\n") + run = self.run_diff() + self.assertEqual(run.returncode, 0, run.stderr) + lines = self.report.read_text().splitlines() + opening = next(i for i, line in enumerate(lines) if line.endswith("diff") and line.startswith("```")) + fence = lines[opening][: -len("diff")] + self.assertEqual(fence, "`" * 6) + # The block closes once, on the last line, with the fence that opened it. + self.assertEqual(lines[-1], fence) + self.assertEqual([i for i, line in enumerate(lines) if line == fence], [len(lines) - 1]) + + def test_a_refusal_cannot_close_the_code_block_either(self): + project_file(self.dir / "head", "memory: 256Mi\n# CRASH\n") + stub = self.dir / "noisy" + stub.write_text(f"#!/usr/bin/env bash\n\"{STUB}\" \"$@\" || {{ echo '```'; echo '# injected'; exit 1; }}\n") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC) + run = self.run_diff(DEPLOY_KIT_COMMAND=str(stub)) + self.assertEqual(run.returncode, 1) + lines = self.report.read_text().splitlines() + self.assertEqual(lines[-1], "````") + self.assertEqual(lines.count("````"), 2) + + def test_a_project_name_that_is_not_one_is_never_used_as_a_path(self): + (self.dir / "head" / "deploy" / "notes.project.yml").write_text("project: Not_A-Name\nmemory: 1Mi\n") + run = self.run_diff() + self.assertEqual(run.returncode, 1) + self.assertIn("could not be packed", run.stderr) + self.assertIn("is not a name", run.stderr) + self.assertFalse(self.report.exists()) + + 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.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): + comments = [ + {"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) + 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_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", + '', + "", + "", + ): + 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()) + + 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()