diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ad388449..5f8b1972 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -138,6 +138,9 @@ jobs: - name: 🧪 Self-test marketplace workflow authorization branches run: bash scripts/marketplace-publication-workflow.test.sh + - name: 🧪 Self-test marketplace publication checkout identity + run: bash scripts/marketplace-publication-identity.test.sh + - name: 🧪 Self-test create-only marketplace proposals run: bash scripts/propose-marketplace-release.test.sh diff --git a/.github/workflows/publish-marketplace-release.yaml b/.github/workflows/publish-marketplace-release.yaml index 8c5f1663..9fc9e446 100644 --- a/.github/workflows/publish-marketplace-release.yaml +++ b/.github/workflows/publish-marketplace-release.yaml @@ -53,9 +53,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} - RELEASE_COMMIT: ${{ github.sha }} CI_RUN: ${{ inputs.ci-run || 'latest' }} run: | + RELEASE_COMMIT=$(git rev-parse --verify HEAD) bash scripts/prepare-merged-marketplace-release.sh \ --repo "$REPOSITORY" --release "$RELEASE_COMMIT" --ci-run "$CI_RUN" \ --output "$RUNNER_TEMP/marketplace-candidate" > "$RUNNER_TEMP/assessment.json" diff --git a/docs/marketplace-releases.md b/docs/marketplace-releases.md index 85b8b950..e3c1d1a9 100644 --- a/docs/marketplace-releases.md +++ b/docs/marketplace-releases.md @@ -106,7 +106,9 @@ this preparation workflow has no publishing job. **Publish marketplace release** supports manual main dispatch and an opt-in hourly check at minute 25 UTC. It uses -current-main tooling and regenerates the candidate from the version proposal's sole parent. Both +current-main tooling and regenerates the candidate from the version proposal's sole parent. The +assessment commit and its output bind to the actual current-main checkout, including when main +advances after dispatch. Both remote main and the named CI run are checked before and after verification. A stale, foreign, failed, PR or unrelated workflow run is refused. An ordinary commit reports `NO_VERSION_CHANGE` and cannot publish. No downloaded artifact supplies publication authority. @@ -219,6 +221,8 @@ reuses the complete candidate reproduction, source parent, two-manifest tree and in a disposable local repository. Only that private repository's copy of the candidate tag is removed for reconstruction. The caller's tags, branches, index and working files are preserved; neither GitHub nor another remote is contacted. Inherited Git layout overrides are neutralized. +Physical checkout pathnames retain all their bytes, including trailing newlines. Verification keeps +configured filesystem observation hooks inert and preserves the caller's index and configuration. Success emits `status: INSPECTED`, `scope: local-historical-content`, and a `localTag` snapshot. `PRESENT` records the tag object, resolved commit and whether it targets the nominated release; @@ -302,6 +306,8 @@ or an open PR touching either marketplace manifest. A complete unrelated PR is n latest selector refuses a pending or failed latest CI run rather than finding an older green one. For renamed PR files, complete REST filename/status records must match GraphQL before the original paths are considered. Moving a manifest away is a conflict; complete unrelated renames are allowed. +The CI workflow identity accepts its plain path or the exact main-qualified forms +`.github/workflows/ci.yaml@main` and `.github/workflows/ci.yaml@refs/heads/main`. Two agreeing observations and byte-exact private reconstruction are required. `NO_CHANGE` performs no writes, including when proposal creation was requested. Evidence artifacts supply no write authority. Read-only candidate-release visibility is the reader's projection; it does not establish visibility of diff --git a/scripts/inspect-marketplace-release.sh b/scripts/inspect-marketplace-release.sh index 99097118..e5122ee5 100755 --- a/scripts/inspect-marketplace-release.sh +++ b/scripts/inspect-marketplace-release.sh @@ -16,8 +16,10 @@ unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT unset GIT_CONFIG GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM export GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 -original=$(git rev-parse --show-toplevel) || fail 'a Git working tree is required' -original=$(cd "$original" && pwd -P) +original=$(git rev-parse --show-toplevel && printf '.') || fail 'a Git working tree is required' +original=${original%$'\n.'} +original=$(cd "$original" && pwd -P && printf '.') || fail 'physical working tree is unavailable' +original=${original%$'\n.'} temp=$(mktemp -d "${TMPDIR:-/tmp}/marketplace-inspect.XXXXXX") trap 'rm -rf "$temp"' EXIT # Check the original before cloning; a local clone need not retain these restrictions. @@ -48,9 +50,10 @@ if [ -n "$tag_oid" ]; then else printf '%s\n' '{"state":"ABSENT","objectOid":null,"commitOid":null,"targetsRelease":null}' > "$temp/local-tag.json" fi -# A separate local repository shares immutable objects, never the caller's refs or index. +# A separate local repository copies immutable objects, never the caller's refs or index. # No checkout, network call or nominated source code is executed. -git clone --shared --no-checkout --quiet "$original" "$temp/repository" +# Shared-clone alternates use line records and cannot retain every physical pathname. +git clone --no-hardlinks --no-checkout --quiet "$original" "$temp/repository" private=$(cd "$temp/repository" && pwd -P) [ "$(git -C "$private" rev-parse --show-toplevel)" = "$private" ] || fail 'private worktree layout is invalid' [ "$(git -C "$private" rev-parse --path-format=absolute --git-common-dir)" = "$private/.git" ] || fail 'private Git directory is invalid' diff --git a/scripts/inspect-marketplace-release.test.sh b/scripts/inspect-marketplace-release.test.sh index fc318631..0a2c258a 100755 --- a/scripts/inspect-marketplace-release.test.sh +++ b/scripts/inspect-marketplace-release.test.sh @@ -77,6 +77,16 @@ run > "$work/default" jq -e '.status=="VERIFIED" and .scope=="local-prepublication"' "$work/default" >/dev/null passed=$((passed+1)) accept 'absent tag is an inspection, never prepublication clearance' ABSENT null +fixture +newline_repo="$repo"$'\n' +mv "$repo" "$newline_repo"; repo=$newline_repo +snapshot "$work/newline-before" +accept 'exact trailing-newline checkout identity' ABSENT null +snapshot "$work/newline-after" +for field in refs status index; do + cmp "$work/newline-before.$field" "$work/newline-after.$field" || fail "newline checkout $field changed" +done +passed=$((passed+1)) git -C "$repo" tag v1.3.0 "$release" if run > "$work/default" 2> "$work/error"; then fail 'default mode accepted occupied tag'; fi [ ! -s "$work/default" ] || fail 'default emitted clearance' diff --git a/scripts/marketplace-observation-completeness.test.sh b/scripts/marketplace-observation-completeness.test.sh index 3ac5b600..cc763ece 100644 --- a/scripts/marketplace-observation-completeness.test.sh +++ b/scripts/marketplace-observation-completeness.test.sh @@ -76,7 +76,9 @@ if { [ "${1:-}" = -C ] && [ "${2:-}" = "$OBS_REPO" ] && [ "${3:-}" = config ]; } esac fi if [ "$OBS_FAULT" = graft ] && [ "${1:-}" = rev-parse ] && [ "${2:-}" = --git-path ]; then exit 1; fi -if [ "$OBS_FAULT" = changes ] && [ "${1:-}" = diff-tree ]; then +if [ "$OBS_FAULT" = changes ] && + { [ "${1:-}" = diff-tree ] || + { [ "${1:-}" = -c ] && [ "${2:-}" = core.fsmonitor=false ] && [ "${3:-}" = diff-tree ]; }; }; then printf '.claude-plugin/marketplace.json\0.github/plugin/marketplace.json\0content' exit 0 fi diff --git a/scripts/marketplace-publication-identity.test.sh b/scripts/marketplace-publication-identity.test.sh new file mode 100644 index 00000000..d4ad98e8 --- /dev/null +++ b/scripts/marketplace-publication-identity.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run the publication workflow's actual assessment shell against an advanced checkout. +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +awk ' + /^ assess:/ {assessment=1} + /^ publish:/ {assessment=0} + assessment && /^ id: prepare$/ {selected=1} + selected && /^ run: \|$/ {reading=1; next} + reading && /^ / {sub(/^ /,""); print; next} + reading {exit} +' "$root/.github/workflows/publish-marketplace-release.yaml" > "$work/step" +[[ -s $work/step ]] || { echo 'FAIL: assessment step is unavailable' >&2; exit 1; } +git init -q "$work/repo" +git -C "$work/repo" config user.name 'Publication identity fixture' +git -C "$work/repo" config user.email fixture@example.invalid +git -C "$work/repo" config commit.gpgsign false +git -C "$work/repo" commit --allow-empty -qm baseline +dispatch=$(git -C "$work/repo" rev-parse HEAD) +git -C "$work/repo" commit --allow-empty -qm current +current=$(git -C "$work/repo" rev-parse HEAD) +mkdir "$work/repo/scripts" "$work/bin" +cat > "$work/repo/scripts/prepare-merged-marketplace-release.sh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'called\n' >> "$CALLED" +release= ci= +while (($#)); do + case $1 in + --release) release=$2; shift 2 ;; + --ci-run) ci=$2; shift 2 ;; + --repo|--output) shift 2 ;; + *) exit 91 ;; + esac +done +[[ $release == "$CURRENT" && $ci == latest ]] || exit 92 +jq -n --arg release "$release" '{status:"NO_VERSION_CHANGE",releaseCommit:$release,ciRunId:42}' +STUB +cat > "$work/bin/git" <<'STUB' +#!/usr/bin/env bash +if [[ ${FAIL_HEAD:-false} == true && " $* " == *' rev-parse '* ]]; then + printf '%s\n' "$CURRENT" + exit 73 +fi +exec "$REAL_GIT" "$@" +STUB +chmod +x "$work/bin/git" +real_git=$(command -v git) +pass=0 fail=0 +for mode in ordinary queued wrong-ci failed-head; do + : > "$work/called"; : > "$work/outputs"; : > "$work/summary" + selected=$current; [[ $mode != queued ]] || selected=$dispatch + ci=latest; [[ $mode != wrong-ci ]] || ci=41 + failed=false; [[ $mode != failed-head ]] || failed=true + status=0 + (cd "$work/repo" && CALLED="$work/called" CURRENT="$current" REAL_GIT="$real_git" \ + FAIL_HEAD=$failed PATH="$work/bin:$PATH" RELEASE_COMMIT="$selected" CI_RUN=$ci \ + REPOSITORY=example/catalogue RUNNER_TEMP="$work" GITHUB_OUTPUT="$work/outputs" \ + GITHUB_STEP_SUMMARY="$work/summary" bash -euo pipefail "$work/step") \ + > "$work/out" 2> "$work/error" || status=$? + ok=false + case $mode in + ordinary|queued) + if [[ $status == 0 ]] && grep -qx "release=$current" "$work/outputs"; then ok=true; fi ;; + wrong-ci) [[ $status != 0 && ! -s $work/outputs ]] && ok=true ;; + failed-head) [[ $status != 0 && ! -s $work/called && ! -s $work/outputs ]] && ok=true ;; + esac + if [[ $ok == true ]]; then printf 'PASS: publication identity %s\n' "$mode"; pass=$((pass+1)) + else printf 'FAIL: publication identity %s (exit %s)\n' "$mode" "$status"; fail=$((fail+1)); fi +done +printf 'Publication identity controls: %s pass, %s fail\n' "$pass" "$fail" +[[ $fail == 0 ]] diff --git a/scripts/propose-marketplace-release.sh b/scripts/propose-marketplace-release.sh index ce0ca7de..a704d607 100644 --- a/scripts/propose-marketplace-release.sh +++ b/scripts/propose-marketplace-release.sh @@ -76,7 +76,7 @@ ci_snapshot() { fi gh api --hostname github.com "repos/$repo/actions/runs/$ci" > "$temp/ci" json_object_unique "$temp/ci" || fail 'ambiguous CI observation' - jq -es --arg repo "$repo" --arg source "$source" --argjson ci "$ci" 'length==1 and (.[0]|.id==$ci and .path==".github/workflows/ci.yaml" and .event=="push" and .status=="completed" and .conclusion=="success" and .head_branch=="main" and .head_sha==$source and .repository.full_name==$repo and .head_repository.full_name==$repo)' "$temp/ci" >/dev/null || fail 'exact successful main CI is required' + jq -es --arg repo "$repo" --arg source "$source" --argjson ci "$ci" 'length==1 and (.[0]|.id==$ci and (.path==".github/workflows/ci.yaml" or .path==".github/workflows/ci.yaml@main" or .path==".github/workflows/ci.yaml@refs/heads/main") and .event=="push" and .status=="completed" and .conclusion=="success" and .head_branch=="main" and .head_sha==$source and .repository.full_name==$repo and .head_repository.full_name==$repo)' "$temp/ci" >/dev/null || fail 'exact successful main CI is required' } # Refuse any local tag-object change since candidate preparation. local_tags_unchanged() { diff --git a/scripts/propose-marketplace-release.test.sh b/scripts/propose-marketplace-release.test.sh index cd44bdf9..67fd4b43 100644 --- a/scripts/propose-marketplace-release.test.sh +++ b/scripts/propose-marketplace-release.test.sh @@ -77,7 +77,18 @@ elif [ "$endpoint" = repos/example/catalogue/actions/runs/42 ]; then if [ "$mode" = duplicate-ci ]; then jq -c . "$FORGE_STATE/ci" | sed 's/^{/{"conclusion":"failure",/'; exit 0 fi - case "$mode" in ci-pending) jq '.status="in_progress"|.conclusion=null' "$FORGE_STATE/ci";; ci-foreign) jq '.head_repository.full_name="other/catalogue"' "$FORGE_STATE/ci";; ci-stale) jq '.head_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' "$FORGE_STATE/ci";; ci-event) jq '.event="workflow_dispatch"' "$FORGE_STATE/ci";; ci-workflow) jq '.path=".github/workflows/other.yaml"' "$FORGE_STATE/ci";; *) cat "$FORGE_STATE/ci";; esac + case "$mode" in + ci-qualified-main) jq '.path=".github/workflows/ci.yaml@main"' "$FORGE_STATE/ci";; + ci-qualified-full-main) jq '.path=".github/workflows/ci.yaml@refs/heads/main"' "$FORGE_STATE/ci";; + ci-qualified-other) jq '.path=".github/workflows/other.yaml@main"' "$FORGE_STATE/ci";; + ci-qualified-feature) jq '.path=".github/workflows/ci.yaml@feature"' "$FORGE_STATE/ci";; + ci-pending) jq '.status="in_progress"|.conclusion=null' "$FORGE_STATE/ci";; + ci-foreign) jq '.head_repository.full_name="other/catalogue"' "$FORGE_STATE/ci";; + ci-stale) jq '.head_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' "$FORGE_STATE/ci";; + ci-event) jq '.event="workflow_dispatch"' "$FORGE_STATE/ci";; + ci-workflow) jq '.path=".github/workflows/other.yaml"' "$FORGE_STATE/ci";; + *) cat "$FORGE_STATE/ci";; + esac elif [ "$endpoint" = graphql ] && [ "$paginated" = true ]; then count=$(cat "$FORGE_STATE/reads" 2>/dev/null || printf 0) count=$((count+1)); printf '%s\n' "$count" > "$FORGE_STATE/reads" @@ -275,6 +286,10 @@ run_case() { passed=$((passed+1)) } run_case 'read-only preparation' none false PREPARED +run_case 'native main-qualified CI path' ci-qualified-main false PREPARED +run_case 'native fully-qualified main CI path' ci-qualified-full-main false PREPARED +run_case 'qualified unrelated workflow refuses' ci-qualified-other false REFUSED +run_case 'qualified feature workflow refuses' ci-qualified-feature false REFUSED run_case 'explicit signed draft creation' none true CREATED run_case 'explicit empty GraphQL errors remain valid' empty-errors true CREATED for fault in null-errors commit-null-errors readback-null-errors; do run_case "$fault is refused" "$fault" true REFUSED; done diff --git a/scripts/verify-marketplace-release.sh b/scripts/verify-marketplace-release.sh index 8811e8cc..56f7a77c 100644 --- a/scripts/verify-marketplace-release.sh +++ b/scripts/verify-marketplace-release.sh @@ -87,7 +87,8 @@ else [ "$parents" = "$source" ] || fail 'release must have exactly the selected source as its only parent' fi # Only the marketplace manifests may change. Include mode changes, deletions and renames. -git diff-tree --no-relative --no-commit-id --name-only -r --no-renames --no-ext-diff -z "$source" "$release" > "$temp/changes" +# Git can consult the caller index even for tree diffs; keep configured observation hooks inert. +git -c core.fsmonitor=false diff-tree --no-relative --no-commit-id --name-only -r --no-renames --no-ext-diff -z "$source" "$release" > "$temp/changes" path='' while IFS= read -r -d '' path; do case "$path" in diff --git a/scripts/verify-marketplace-release.test.sh b/scripts/verify-marketplace-release.test.sh index de2cec52..45157abc 100644 --- a/scripts/verify-marketplace-release.test.sh +++ b/scripts/verify-marketplace-release.test.sh @@ -64,7 +64,7 @@ exec "$REAL_FIND" "$@" STUB cat > "$work/bin/git" <<'STUB' #!/usr/bin/env bash -[[ ${DIFF_MODE:-} != empty || $1 != diff-tree ]] || exit 0 +[[ ${DIFF_MODE:-} != empty || " $* " != *' diff-tree '* ]] || exit 0 exec "$REAL_GIT" "$@" STUB chmod +x "$work/bin/find" "$work/bin/git" @@ -80,6 +80,22 @@ git -C "$repo" add content; git -C "$repo" commit --amend --no-edit -q release=$(git -C "$repo" rev-parse HEAD) PATH="$work/bin:$PATH" DIFF_MODE=empty REAL_GIT="$real_git" REAL_FIND="$real_find" reject 'empty successful diff inventory cannot hide unrelated content' incremental; accept 'single-parent manifest-only release' 1.3.0 +# Observation must not execute a checkout-configured filesystem hook. +export FS_MARKER="$work/fsmonitor-called" +cat > "$work/fsmonitor" <<'HOOK' +#!/bin/sh +printf 'observed\n' >> "$FS_MARKER" +exit 1 +HOOK +chmod +x "$work/fsmonitor" +git -C "$repo" config core.fsmonitor "$work/fsmonitor" +cp "$repo/.git/index" "$work/index-before" +cp "$repo/.git/config" "$work/config-before" +accept 'filesystem observation hook is inert' 1.3.0 +[ ! -e "$FS_MARKER" ] || fail 'verification executed a filesystem observation hook' +cmp -s "$repo/.git/index" "$work/index-before" || fail 'verification changed the caller index' +cmp -s "$repo/.git/config" "$work/config-before" || fail 'verification changed the caller configuration' +passed=$((passed+1)) incremental unusual_repo="$repo$(printf '\001'):quoted\\path" mv "$repo" "$unusual_repo"; repo="$unusual_repo"