diff --git a/scripts/build-release.test.sh b/scripts/build-release.test.sh index 917261e..8882547 100755 --- a/scripts/build-release.test.sh +++ b/scripts/build-release.test.sh @@ -98,7 +98,9 @@ done [[ -x "${CASE_DIR}/release/scripts/configure.sh" ]] || fail 'configure.sh lost executable mode' [[ -x "${CASE_DIR}/release/scripts/teardown.sh" ]] || fail 'teardown.sh lost executable mode' -[[ -x "${CASE_DIR}/release/scripts/scrub-lockfile.sh" ]] || fail 'scrub-lockfile.sh lost executable mode' +# The GraphQL publisher creates new files as 100644. This helper is invoked via +# bash (including the packaged controller below), so it needs no executable bit. +[[ ! -x "${CASE_DIR}/release/scripts/scrub-lockfile.sh" ]] || fail 'scrub-lockfile.sh must be non-executable for signed publication' grep -Fq 'bash "$GITHUB_ACTION_PATH/scripts/configure.sh"' "${CASE_DIR}/release/action.yml" || fail 'root action does not invoke its shipped configure script' grep -Fq 'bash "$GITHUB_ACTION_PATH/../scripts/teardown.sh"' "${CASE_DIR}/release/teardown/action.yml" || fail 'teardown action does not invoke its shipped teardown script' diff --git a/scripts/publish-release.test.sh b/scripts/publish-release.test.sh index 1a641f5..c102604 100755 --- a/scripts/publish-release.test.sh +++ b/scripts/publish-release.test.sh @@ -80,6 +80,24 @@ fi case "$endpoint" in graphql) cp "$input" "${FAKE_GH_STATE}/graphql-payload.json" + if [[ -n "${FAKE_BASE_TREE:-}" ]]; then + # Model FileAddition: preserve an existing file's mode; new files are + # regular 100644 blobs. Build the result from the actual mutation payload, + # independently of the publisher's desired tree hash. + export GIT_INDEX_FILE="${FAKE_GH_STATE}/api-index" + git -C "$FAKE_GIT_ROOT" read-tree "$FAKE_BASE_TREE" + while IFS= read -r path; do + git -C "$FAKE_GIT_ROOT" update-index --force-remove -- "$path" + done < <(jq -r '.variables.input.fileChanges.deletions[].path' "$input") + while IFS=$'\t' read -r path contents; do + entry="$(git -C "$FAKE_GIT_ROOT" ls-files --stage -- "$path")" + mode="${entry%% *}" + mode="${mode:-100644}" + blob="$(printf '%s' "$contents" | base64 --decode | git -C "$FAKE_GIT_ROOT" hash-object -w --stdin)" + git -C "$FAKE_GIT_ROOT" update-index --add --cacheinfo "${mode},${blob},${path}" + done < <(jq -r '.variables.input.fileChanges.additions[] | [.path, .contents] | @tsv' "$input") + git -C "$FAKE_GIT_ROOT" write-tree >"${FAKE_GH_STATE}/api-tree" + fi printf '{"data":{"createCommitOnBranch":{"commit":{"oid":"5555555555555555555555555555555555555555","signature":{"isValid":%s,"wasSignedByGitHub":%s,"state":"%s"}}}}}\n' \ "${FAKE_SIGNATURE_VALID:-true}" "${FAKE_SIGNED_BY_GITHUB:-true}" "${FAKE_SIGNATURE_STATE:-VALID}" ;; @@ -103,10 +121,21 @@ case "$endpoint" in printf '%s\n' "${FAKE_EXISTING_TREE:-4444444444444444444444444444444444444444}" ;; */git/commits/5555555555555555555555555555555555555555) - printf '%s\n' "${FAKE_PUBLISHED_TREE:-${FAKE_DESIRED_TREE}}" + if [[ -n "${FAKE_BASE_TREE:-}" ]]; then + cat "${FAKE_GH_STATE}/api-tree" + else + printf '%s\n' "${FAKE_PUBLISHED_TREE:-${FAKE_DESIRED_TREE}}" + fi ;; */git/trees/*) - printf '{"truncated":false,"tree":[{"path":"obsolete.txt","type":"blob","mode":"100644","sha":"9999999999999999999999999999999999999999"}]}\n' + if [[ -n "${FAKE_BASE_TREE:-}" ]]; then + git -C "$FAKE_GIT_ROOT" ls-tree -r "$FAKE_BASE_TREE" | jq -Rn ' + {truncated: false, tree: [inputs | split("\t") as $row | + ($row[0] | split(" ")) as $object | + {mode: $object[0], type: $object[1], sha: $object[2], path: $row[1]}]}' + else + printf '{"truncated":false,"tree":[{"path":"obsolete.txt","type":"blob","mode":"100644","sha":"9999999999999999999999999999999999999999"}]}\n' + fi ;; */git/refs | */git/refs/*) printf '{}\n' @@ -169,6 +198,53 @@ jq -e --arg parent "$EXISTING_SHA" '.variables.input.expectedHeadOid == $parent' grep -Fq 'PATCH repos/workos/setup-socket-firewall/git/refs/heads/action-release/v1' "${STATE_DIR}/calls" || fail 'release branch was not moved' grep -Fq 'PATCH repos/workos/setup-socket-firewall/git/refs/tags/v1' "${STATE_DIR}/calls" || fail 'v1 tag was not moved' +# Reproduce the five-file pre-scrub release: the older shell helpers already +# have executable modes; every other runtime file must be added by the API. +base_index="${CASE_DIR}/base-index" +GIT_INDEX_FILE="$base_index" git -C "$ROOT" read-tree --empty +for path in LICENSE action.yml scripts/configure.sh scripts/teardown.sh teardown/action.yml; do + read -r mode _ blob _ < <(git -C "$ROOT" ls-tree -r "$DESIRED_TREE" -- "$path") + GIT_INDEX_FILE="$base_index" git -C "$ROOT" update-index --add --cacheinfo "${mode},${blob},${path}" +done +base_tree="$(GIT_INDEX_FILE="$base_index" git -C "$ROOT" write-tree)" + +publish_from_previous_release() { + FAKE_BASE_TREE="$base_tree" \ + FAKE_GIT_ROOT="$ROOT" \ + FAKE_EXISTING_RELEASE=true \ + FAKE_EXISTING_TREE="$base_tree" \ + FAKE_EXISTING_TAG=true \ + GH_BIN="$fake_gh" \ + GITHUB_REPOSITORY=workos/setup-socket-firewall \ + "${ROOT}/scripts/publish-release.sh" "$1" "$SOURCE_SHA" v1 +} + +# The current package must match the mode-aware API result without relaxing the +# exact-tree check. This fails on the original 100755 scrub helper from PR #10. +new_state +publish_from_previous_release "$release_dir" >/dev/null +assert_line "$GITHUB_OUTPUT" "release_sha=${NEW_SHA}" +assert_line "$GITHUB_OUTPUT" 'released=true' +assert_line "${STATE_DIR}/api-tree" "$DESIRED_TREE" +assert_line "${STATE_DIR}/calls" 'PATCH repos/workos/setup-socket-firewall/git/refs/heads/action-release/v1' +assert_line "${STATE_DIR}/calls" 'PATCH repos/workos/setup-socket-firewall/git/refs/tags/v1' + +# Reintroducing the unnecessary executable bit reproduces the production error +# and must leave both discovery refs untouched, while cleaning the staging ref. +broken_release="${CASE_DIR}/executable-scrub-release" +cp -R "$release_dir" "$broken_release" +chmod +x "${broken_release}/scripts/scrub-lockfile.sh" +new_state +if publish_from_previous_release "$broken_release" >"${STATE_DIR}/error" 2>&1; then + fail 'new executable scrub helper unexpectedly published' +fi +grep -Fq 'GitHub-signed release commit tree differs from the staged tree' "${STATE_DIR}/error" || fail 'wrong failure for executable scrub helper' +[[ ! -s "$GITHUB_OUTPUT" ]] || fail 'mismatched release emitted success outputs' +if grep -Eq 'PATCH repos/.*/git/refs/(heads/action-release/v1|tags/v1)' "${STATE_DIR}/calls"; then + fail 'executable mode mismatch moved a release ref' +fi +assert_line "${STATE_DIR}/calls" 'DELETE repos/workos/setup-socket-firewall/git/refs/heads/action-release-staging/v1-12345-1' + # Never publish release refs when GitHub does not sign the staged commit. new_state set +e diff --git a/scripts/scrub-lockfile.sh b/scripts/scrub-lockfile.sh old mode 100755 new mode 100644