From 03c57963592a9d9a5e909dc9de8b782696aa448e Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Sun, 6 Sep 2026 18:39:33 +0800 Subject: [PATCH 1/6] fix(release): pin compare-link start to CHANGELOG's second versioned heading The compare-link check used a `*` glob between `/compare/` and `...v`, so any start version passed validation. Derive the expected start from the CHANGELOG's second versioned heading and compare the full link as an exact string; fail closed when the heading is missing. Also fix the releases/tag branch, whose `[${version}]` was a glob character class rather than a literal match. Bidirectional: 4 previously-passing tampered links now fail; correct compare and releases/tag links still pass. --- scripts/validate_release_version.sh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/validate_release_version.sh b/scripts/validate_release_version.sh index 987a304..937ac84 100755 --- a/scripts/validate_release_version.sh +++ b/scripts/validate_release_version.sh @@ -93,11 +93,29 @@ while IFS= read -r version_link; do done < <(grep -F -- "[${version}]: " "${changelog}" || true) [[ "${#version_links[@]}" == 1 ]] || die "CHANGELOG.md: expected exactly one [${version}] comparison link" -if [[ "${version_links[0]}" != \ - "[${version}]: https://github.com/bytefolk/mem/releases/tag/v${version}" && - "${version_links[0]}" != \ - "[${version}]: https://github.com/bytefolk/mem/compare/"*"...v${version}" ]]; then - die "CHANGELOG.md: [${version}] link must terminate at v${version}" +compare_base="$(awk ' + /^## \[/ && $0 != "## [Unreleased]" { + if (seen++) { + gsub(/^## \[/, "", $0) + gsub(/\].*/, "", $0) + print $0 + exit + } + } +' "${changelog}")" + +expected_compare_link= +if [[ -n "${compare_base}" ]]; then + expected_compare_link="[${version}]: https://github.com/bytefolk/mem/compare/v${compare_base}...v${version}" +fi +expected_release_link="[${version}]: https://github.com/bytefolk/mem/releases/tag/v${version}" + +if [[ "${version_links[0]}" != "${expected_release_link}" ]] && + [[ -z "${expected_compare_link}" || "${version_links[0]}" != "${expected_compare_link}" ]]; then + if [[ -z "${compare_base}" ]]; then + die "CHANGELOG.md: cannot derive compare base (need a second versioned heading below [${version}])" + fi + die "CHANGELOG.md: [${version}] link must be exactly:"$'\n'" ${expected_release_link}"$'\n'" or:"$'\n'" ${expected_compare_link}" fi printf 'PASS: all release version surfaces match %s\n' "${version}" From 1624cf7484f34e6b10c216ec31eda0652e2cd9b4 Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Thu, 10 Sep 2026 09:47:59 +0800 Subject: [PATCH 2/6] fix(release): portable find and safe empty-array guard for macOS Replace GNU find -printf with -exec basename for macOS compatibility. Guard actual_assets expansion under set -u to prevent unbound variable error when the asset directory is empty. --- scripts/generate_release_checksums.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate_release_checksums.sh b/scripts/generate_release_checksums.sh index 4fb27fd..a99d96c 100755 --- a/scripts/generate_release_checksums.sh +++ b/scripts/generate_release_checksums.sh @@ -30,9 +30,9 @@ actual_assets=() while IFS= read -r actual_asset; do actual_assets[${#actual_assets[@]}]="${actual_asset}" done < <( - find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort + find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -exec basename {} + | LC_ALL=C sort ) -if [[ "${actual_assets[*]}" != "${assets[*]}" ]]; then +if [[ "${#actual_assets[@]}" -eq 0 ]] || [[ "${actual_assets[*]}" != "${assets[*]}" ]]; then printf 'ERROR: release assets differ from the exact expected set\n' >&2 printf 'expected: %s\n' "${assets[*]}" >&2 printf 'actual: %s\n' "${actual_assets[*]:-}" >&2 From 59c8b1cdf030d2169591b6e72faf86fa867d2325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:23:15 +0800 Subject: [PATCH 3/6] chore(web): refresh audited development dependencies (cherry picked from commit 11e02e21ef2c3dbd2dae26e4376872e54e78ecb5) --- web/package-lock.json | 100 +++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 103ae42..28a95f0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2161,16 +2161,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2179,13 +2179,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2206,9 +2206,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2219,13 +2219,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2233,14 +2233,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2249,9 +2249,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2259,13 +2259,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3784,9 +3784,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4847,9 +4847,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6009,19 +6009,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6049,12 +6049,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From fa2c30cc693bc0ae1e679e7d108d59aef1bd4007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:23:13 +0800 Subject: [PATCH 4/6] fix(release): validate asset enumeration with GNU tools --- CHANGELOG.md | 3 ++ scripts/generate_release_checksums.sh | 2 +- scripts/test_release_guards.sh | 54 ++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b02f..db25a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Release validation requires comparison links to start at the preceding + CHANGELOG release. Checksum generation handles each asset path separately on + GNU and BSD tools, including directories with spaces, and rejects empty sets. - The npm installer no longer aborts a concurrent first run on Windows. The per-asset cache lock previously treated only `EEXIST` as contention, but a contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process diff --git a/scripts/generate_release_checksums.sh b/scripts/generate_release_checksums.sh index a99d96c..cfc57ff 100755 --- a/scripts/generate_release_checksums.sh +++ b/scripts/generate_release_checksums.sh @@ -30,7 +30,7 @@ actual_assets=() while IFS= read -r actual_asset; do actual_assets[${#actual_assets[@]}]="${actual_asset}" done < <( - find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -exec basename {} + | LC_ALL=C sort + find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort ) if [[ "${#actual_assets[@]}" -eq 0 ]] || [[ "${actual_assets[*]}" != "${assets[*]}" ]]; then printf 'ERROR: release assets differ from the exact expected set\n' >&2 diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 4366a82..88cb828 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -99,6 +99,47 @@ fi expect_failure "version mismatch" \ "${repo_root}/scripts/validate_release_version.sh" 999.999.999 +# Keep CHANGELOG mutations in a fixture tree. All other version surfaces remain +# the real checkout, so failures below must reach the comparison-link guard. +version_fixture="${tmp_dir}/version fixture" +mkdir -p -- "${version_fixture}/scripts" +cp -- "${repo_root}/scripts/validate_release_version.sh" "${version_fixture}/scripts/" +for surface in npm server worker web deploy docs; do + ln -s -- "${repo_root}/${surface}" "${version_fixture}/${surface}" +done +fixture_validator="${version_fixture}/scripts/validate_release_version.sh" +previous_version=0.0.1 + +write_changelog_fixture() { + local link="$1" + local include_previous="${2:-yes}" + { + printf '## [Unreleased]\n\n## [%s] - 2026-01-01\n\n' "${current_version}" + if [[ "${include_previous}" == yes ]]; then + printf '## [%s] - 2025-01-01\n\n' "${previous_version}" + fi + printf '[Unreleased]: https://github.com/bytefolk/mem/compare/v%s...HEAD\n' "${current_version}" + printf '[%s]: https://github.com/bytefolk/mem/%s\n' "${current_version}" "${link}" + } > "${version_fixture}/CHANGELOG.md" +} + +correct_compare="compare/v${previous_version}...${current_tag}" +write_changelog_fixture "${correct_compare}" +"${fixture_validator}" "${current_version}" >/dev/null +for wrong_base in v0.0.0 "${current_tag}" arbitrary; do + write_changelog_fixture "compare/${wrong_base}...${current_tag}" + expect_failure "wrong compare base ${wrong_base}" "${fixture_validator}" "${current_version}" +done +write_changelog_fixture "compare/v${previous_version}...v999.999.999" +expect_failure "wrong compare endpoint" "${fixture_validator}" "${current_version}" +write_changelog_fixture "${correct_compare}/extra" +expect_failure "compare link suffix" "${fixture_validator}" "${current_version}" +write_changelog_fixture "${correct_compare}" no +expect_failure "missing compare predecessor" "${fixture_validator}" "${current_version}" +write_changelog_fixture "releases/tag/${current_tag}" no +"${fixture_validator}" "${current_version}" >/dev/null +printf 'PASS: compare links require the exact predecessor and endpoint; tag links remain valid\n' + notes_file="${tmp_dir}/release-notes.md" "${repo_root}/scripts/render_release_notes.sh" "${current_tag}" > "${notes_file}" [[ -s "${notes_file}" ]] || die "release notes are empty" @@ -190,8 +231,17 @@ expect_failure "annotated tag version mismatch" env \ FAKE_HEAD_COMMIT="${same_commit}" \ "${repo_root}/scripts/validate_release_source.sh" v999.999.999 -asset_dir="${tmp_dir}/assets" +asset_dir="${tmp_dir}/assets with spaces" mkdir -p -- "${asset_dir}" +# An empty set must fail with the intended diagnostic, including on Bash 3.2. +if "${repo_root}/scripts/generate_release_checksums.sh" \ + "${current_tag}" "${same_commit}" "${asset_dir}" > "${tmp_dir}/empty-assets.log" 2>&1; then + die "empty asset directory: command unexpectedly succeeded" +fi +grep -Fq -- 'actual: ' "${tmp_dir}/empty-assets.log" || + die "empty asset directory must report the missing set" +[[ ! -e "${asset_dir}/mem-mcp-checksums.txt" ]] || + die "empty asset directory must not produce a manifest" assets=( mem-mcp-darwin-amd64 mem-mcp-darwin-arm64 @@ -203,6 +253,8 @@ assets=( for asset in "${assets[@]}"; do printf 'test payload for %s\n' "${asset}" > "${asset_dir}/${asset}" done +# Use the real find, basename and sha256sum here. Unlike the Bash-only compat +# suite, this must also catch GNU basename rejecting batched find -exec paths. "${repo_root}/scripts/generate_release_checksums.sh" \ "${current_tag}" "${same_commit}" "${asset_dir}" >/dev/null From 260710d69a66f919520e5a4dccc3749335eb95fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:19:57 +0800 Subject: [PATCH 5/6] fix(release): reject existing checksum output paths Address a baseline manifest-output symlink gap independently of the earlier basename enumeration fix. Preserve external files, reject existing output paths before hashing and publication, and cover late symlinks and randomized temporary staging. --- CHANGELOG.md | 2 + scripts/generate_release_checksums.sh | 9 ++ .../test_release_checksum_output_safety.sh | 102 ++++++++++++++++++ scripts/test_release_guards.sh | 1 + 4 files changed, 114 insertions(+) create mode 100644 scripts/test_release_checksum_output_safety.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index db25a25..e500841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Checksum manifest generation rejects existing output files, directories and + symlinks without modifying their targets, including dangling symlinks. - Release validation requires comparison links to start at the preceding CHANGELOG release. Checksum generation handles each asset path separately on GNU and BSD tools, including directories with spaces, and rejects empty sets. diff --git a/scripts/generate_release_checksums.sh b/scripts/generate_release_checksums.sh index cfc57ff..9f939e3 100755 --- a/scripts/generate_release_checksums.sh +++ b/scripts/generate_release_checksums.sh @@ -11,11 +11,19 @@ die() { exit 1 } +require_absent_output() { + # -e alone misses dangling symlinks. In particular, mv follows an output + # symlink to a directory and would publish outside this asset directory. + [[ ! -e "${output}" && ! -L "${output}" ]] || + die "checksum output path already exists: ${output}" +} + if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?$ ]]; then die "invalid release tag: ${tag:-}" fi [[ "${commit}" =~ ^[0-9a-f]{40}$ ]] || die "invalid release commit: ${commit:-}" [[ -d "${asset_dir}" ]] || die "asset directory does not exist: ${asset_dir:-}" +require_absent_output assets=( mem-mcp-darwin-amd64 @@ -57,6 +65,7 @@ trap cleanup EXIT sha256sum "${assets[@]}" ) } > "${tmp_output}" +require_absent_output mv -- "${tmp_output}" "${output}" trap - EXIT diff --git a/scripts/test_release_checksum_output_safety.sh b/scripts/test_release_checksum_output_safety.sh new file mode 100644 index 0000000..faa8626 --- /dev/null +++ b/scripts/test_release_checksum_output_safety.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +generator="${1:-${repo_root}/scripts/generate_release_checksums.sh}" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mem-checksum-output.XXXXXX")" +trap 'rm -rf -- "${tmp_dir}"' EXIT + +die() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assets=( + mem-mcp-darwin-amd64 + mem-mcp-darwin-arm64 + mem-mcp-linux-amd64 + mem-mcp-linux-arm64 + mem-mcp-windows-amd64.exe + mem-mcp-windows-arm64.exe +) +commit=1111111111111111111111111111111111111111 + +prepare() { + case_root="${tmp_dir}/${1} with spaces" + asset_dir="${case_root}/assets with spaces" + outside="${case_root}/outside with spaces" + mkdir -p -- "${asset_dir}" "${outside}" + for asset in "${assets[@]}"; do + printf 'test payload for %s\n' "${asset}" > "${asset_dir}/${asset}" + done + printf 'external data must remain unchanged\n' > "${outside}/keep.txt" + output="${asset_dir}/mem-mcp-checksums.txt" +} + +snapshot_files() { + find "${case_root}" -type f -exec sha256sum {} \; | LC_ALL=C sort +} + +for kind in symlink-directory symlink-file dangling-symlink directory regular-file; do + prepare "${kind}" + case "${kind}" in + symlink-directory) ln -s -- "${outside}" "${output}" ;; + symlink-file) ln -s -- "${outside}/keep.txt" "${output}" ;; + dangling-symlink) ln -s -- "${outside}/not-created.txt" "${output}" ;; + directory) mkdir -- "${output}" ;; + regular-file) printf 'existing manifest must remain unchanged\n' > "${output}" ;; + esac + before="$(snapshot_files)" + status=0 + bash "${generator}" v0.1.1 "${commit}" "${asset_dir}" > "${tmp_dir}/result.log" 2>&1 || status=$? + # Check side effects before status: the original directory-symlink bug may + # return failure only after mv has already written outside the asset tree. + [[ "$(snapshot_files)" == "${before}" ]] || + die "${kind}: output publication changed existing data or created an unexpected file" + [[ "${status}" -ne 0 ]] || die "${kind}: existing output was accepted" + grep -Fq 'checksum output path already exists' "${tmp_dir}/result.log" || + die "${kind}: missing output-path diagnostic" + case "${kind}" in + symlink-directory) [[ -L "${output}" && "$(readlink "${output}")" == "${outside}" ]] ;; + symlink-file) [[ -L "${output}" && "$(readlink "${output}")" == "${outside}/keep.txt" ]] ;; + dangling-symlink) [[ -L "${output}" && "$(readlink "${output}")" == "${outside}/not-created.txt" ]] ;; + directory) [[ -d "${output}" && ! -L "${output}" ]] ;; + regular-file) [[ -f "${output}" && ! -L "${output}" ]] ;; + esac || die "${kind}: existing output path was replaced" + printf 'PASS: %s rejected without changing external data or output path\n' "${kind}" +done + +# Simulate an output symlink appearing while checksum generation is in progress. +# The second absence check must reject it and cleanup only our own staging file. +prepare output-created-during-hashing +fake_bin="${case_root}/hash tools" +mkdir -p -- "${fake_bin}" +printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ + "\"\${REAL_SHA256SUM}\" \"\$@\"" \ + "ln -s -- \"\${OUTPUT_TARGET}\" \"\${OUTPUT_MANIFEST}\"" > "${fake_bin}/sha256sum" +chmod +x "${fake_bin}/sha256sum" +before="$(snapshot_files)" +status=0 +REAL_SHA256SUM="$(command -v sha256sum)" OUTPUT_TARGET="${outside}" OUTPUT_MANIFEST="${output}" \ + PATH="${fake_bin}:${PATH}" bash "${generator}" v0.1.1 "${commit}" "${asset_dir}" \ + > "${tmp_dir}/result.log" 2>&1 || status=$? +[[ "${status}" -ne 0 ]] || die 'late output symlink was accepted' +[[ "$(snapshot_files)" == "${before}" ]] || die 'late output symlink leaked staging data or changed external files' +[[ -L "${output}" && "$(readlink "${output}")" == "${outside}" ]] || die 'late output symlink was replaced' +grep -Fq 'checksum output path already exists' "${tmp_dir}/result.log" || die 'late output symlink lacks diagnostic' +printf 'PASS: late output symlink rejected and private staging file cleaned up\n' + +# The mktemp template is not a predictable staging filename. A pre-existing +# template-shaped symlink must remain untouched while a fresh manifest works. +prepare unique-temporary-file +ln -s -- "${outside}/keep.txt" "${asset_dir}/.mem-mcp-checksums.XXXXXX" +before="$(sha256sum "${outside}/keep.txt")" +bash "${generator}" v0.1.1 "${commit}" "${asset_dir}" >/dev/null +[[ "$(sha256sum "${outside}/keep.txt")" == "${before}" ]] || die 'temporary output overwrote external data' +[[ -L "${asset_dir}/.mem-mcp-checksums.XXXXXX" ]] || die 'temporary symlink was replaced' +[[ -f "${output}" && ! -L "${output}" ]] || die 'fresh manifest was not created' +( + cd -- "${asset_dir}" + sha256sum --check --strict mem-mcp-checksums.txt >/dev/null +) +printf 'PASS: unpredictable temporary output preserves a pre-existing template-shaped symlink\n' diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 88cb828..c40afe8 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -291,4 +291,5 @@ expect_failure "symlink asset" \ "${repo_root}/scripts/generate_release_checksums.sh" \ "${current_tag}" "${same_commit}" "${asset_dir}" +bash "${repo_root}/scripts/test_release_checksum_output_safety.sh" printf 'PASS: release source, notes, asset-set and checksum guards fail closed\n' From 23fe076deda26f1ba84492c8aa6b4903fa6cd9d4 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:48:39 +0800 Subject: [PATCH 6/6] fix(release): close the compare-link bypass and guard portability A release link pointing at its own tag page satisfied the validator even when a preceding release existed, so the property the PR claims to enforce was enforceable only by maintainer habit. The correct form is now decided by whether a predecessor exists, and a regression test pins the bypass shut. - derive the compare base only from versioned headings, so a non-version heading between releases cannot be mistaken for the predecessor - count manifest rows without wc -l in both guard suites; BSD wc pads the count with blanks, which made the six-row assertion fail on stock macOS - assert manifest completeness in the generator itself, because the post-publish self-check uses --ignore-missing and tolerates a lost row - capture the asset listing before consuming it, so a failed listing is reported as a listing failure rather than an asset-set mismatch - make the new suite executable and add it to the ShellCheck list --- .github/workflows/memory-validation.yml | 1 + scripts/generate_release_checksums.sh | 18 ++++++++++++--- .../test_release_checksum_output_safety.sh | 0 scripts/test_release_guards.sh | 9 ++++++-- scripts/test_release_helpers_compat.sh | 7 +++++- scripts/validate_release_version.sh | 22 +++++++++++-------- 6 files changed, 42 insertions(+), 15 deletions(-) mode change 100644 => 100755 scripts/test_release_checksum_output_safety.sh diff --git a/.github/workflows/memory-validation.yml b/.github/workflows/memory-validation.yml index ed42033..f7f2943 100644 --- a/.github/workflows/memory-validation.yml +++ b/.github/workflows/memory-validation.yml @@ -53,6 +53,7 @@ jobs: scripts/acceptance_agent_memory.sh \ scripts/generate_release_checksums.sh \ scripts/render_release_notes.sh \ + scripts/test_release_checksum_output_safety.sh \ scripts/test_release_helpers_compat.sh \ scripts/test_release_guards.sh \ scripts/test_validate_release_action_pins_compat.sh \ diff --git a/scripts/generate_release_checksums.sh b/scripts/generate_release_checksums.sh index 9f939e3..69aad34 100755 --- a/scripts/generate_release_checksums.sh +++ b/scripts/generate_release_checksums.sh @@ -34,12 +34,18 @@ assets=( mem-mcp-windows-arm64.exe ) +# A process substitution hides find's exit status from the loop below, so a +# tool failure would surface as the misleading "differ from the exact expected +# set". Capture the listing first and report a failed listing as what it is. +asset_listing="$( + find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort +)" || die "cannot list release assets in ${asset_dir}" + actual_assets=() while IFS= read -r actual_asset; do + [[ -n "${actual_asset}" ]] || continue actual_assets[${#actual_assets[@]}]="${actual_asset}" -done < <( - find "${asset_dir}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort -) +done <<< "${asset_listing}" if [[ "${#actual_assets[@]}" -eq 0 ]] || [[ "${actual_assets[*]}" != "${assets[*]}" ]]; then printf 'ERROR: release assets differ from the exact expected set\n' >&2 printf 'expected: %s\n' "${assets[*]}" >&2 @@ -65,6 +71,12 @@ trap cleanup EXIT sha256sum "${assets[@]}" ) } > "${tmp_output}" +# The post-publish self-check below uses --ignore-missing, which by definition +# tolerates a listed file being absent, so completeness is asserted here while +# the staging file and the expected set are both known. +manifest_rows="$(grep -c '' "${tmp_output}")" +[[ "${manifest_rows}" -eq "${#assets[@]}" ]] || + die "checksum manifest must have ${#assets[@]} rows, got ${manifest_rows}" require_absent_output mv -- "${tmp_output}" "${output}" trap - EXIT diff --git a/scripts/test_release_checksum_output_safety.sh b/scripts/test_release_checksum_output_safety.sh old mode 100644 new mode 100755 diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index c40afe8..dacb16e 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -138,7 +138,11 @@ write_changelog_fixture "${correct_compare}" no expect_failure "missing compare predecessor" "${fixture_validator}" "${current_version}" write_changelog_fixture "releases/tag/${current_tag}" no "${fixture_validator}" "${current_version}" >/dev/null -printf 'PASS: compare links require the exact predecessor and endpoint; tag links remain valid\n' +# A tag link is only legitimate when no release precedes this one. Once a +# predecessor exists, pointing at the tag page must not satisfy the guard. +write_changelog_fixture "releases/tag/${current_tag}" +expect_failure "tag link replaces the predecessor" "${fixture_validator}" "${current_version}" +printf 'PASS: compare links require the exact predecessor and endpoint; tag links are valid only without a predecessor\n' notes_file="${tmp_dir}/release-notes.md" "${repo_root}/scripts/render_release_notes.sh" "${current_tag}" > "${notes_file}" @@ -259,7 +263,8 @@ done "${current_tag}" "${same_commit}" "${asset_dir}" >/dev/null manifest="${asset_dir}/mem-mcp-checksums.txt" -[[ "$(wc -l < "${manifest}")" == 6 ]] || die "checksum manifest must have six rows" +# BSD wc pads its count with blanks, so a line count must not come from wc -l. +[[ "$(grep -c '' "${manifest}")" == 6 ]] || die "checksum manifest must have six rows" ( cd -- "${asset_dir}" sha256sum --check --strict "$(basename -- "${manifest}")" >/dev/null diff --git a/scripts/test_release_helpers_compat.sh b/scripts/test_release_helpers_compat.sh index 0757d1f..1e8ba2e 100755 --- a/scripts/test_release_helpers_compat.sh +++ b/scripts/test_release_helpers_compat.sh @@ -75,6 +75,11 @@ if ! ( exit 1 fi -[[ "$(wc -l < "${asset_dir}/mem-mcp-checksums.txt")" == 6 ]] +# This suite is the one that claims independence from GNU find and coreutils, +# so it must not count lines with wc -l: BSD wc pads the count with blanks. +[[ "$(grep -c '' "${asset_dir}/mem-mcp-checksums.txt")" == 6 ]] || { + printf 'ERROR: checksum manifest must have six rows\n' >&2 + exit 1 +} printf 'PASS: release version and checksum helpers run without Bash 4-only collection builtins\n' diff --git a/scripts/validate_release_version.sh b/scripts/validate_release_version.sh index 937ac84..e063ac3 100755 --- a/scripts/validate_release_version.sh +++ b/scripts/validate_release_version.sh @@ -94,7 +94,7 @@ done < <(grep -F -- "[${version}]: " "${changelog}" || true) [[ "${#version_links[@]}" == 1 ]] || die "CHANGELOG.md: expected exactly one [${version}] comparison link" compare_base="$(awk ' - /^## \[/ && $0 != "## [Unreleased]" { + /^## \[[0-9]/ { if (seen++) { gsub(/^## \[/, "", $0) gsub(/\].*/, "", $0) @@ -104,18 +104,22 @@ compare_base="$(awk ' } ' "${changelog}")" -expected_compare_link= +# Exactly one link form is correct, decided by whether a release precedes this +# one: with a predecessor the link must start at that release; without one (a +# first release such as 0.1.0) there is nothing to compare from, so the release +# keeps its tag link. Accepting the tag form in both cases would let a later +# release dodge the predecessor requirement. if [[ -n "${compare_base}" ]]; then - expected_compare_link="[${version}]: https://github.com/bytefolk/mem/compare/v${compare_base}...v${version}" + expected_link="[${version}]: https://github.com/bytefolk/mem/compare/v${compare_base}...v${version}" +else + expected_link="[${version}]: https://github.com/bytefolk/mem/releases/tag/v${version}" fi -expected_release_link="[${version}]: https://github.com/bytefolk/mem/releases/tag/v${version}" -if [[ "${version_links[0]}" != "${expected_release_link}" ]] && - [[ -z "${expected_compare_link}" || "${version_links[0]}" != "${expected_compare_link}" ]]; then - if [[ -z "${compare_base}" ]]; then - die "CHANGELOG.md: cannot derive compare base (need a second versioned heading below [${version}])" +if [[ "${version_links[0]}" != "${expected_link}" ]]; then + if [[ -n "${compare_base}" ]]; then + die "CHANGELOG.md: [${version}] link must start at the preceding release v${compare_base}:"$'\n'" ${expected_link}" fi - die "CHANGELOG.md: [${version}] link must be exactly:"$'\n'" ${expected_release_link}"$'\n'" or:"$'\n'" ${expected_compare_link}" + die "CHANGELOG.md: nothing precedes [${version}], so its link must be exactly:"$'\n'" ${expected_link}" fi printf 'PASS: all release version surfaces match %s\n' "${version}"