Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .codex/workflows/release-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ the tag name, but the tag/`version.json` consistency is also worth checking at
the source-tree level before any artifact is built. Run this immediately after
the tag is pushed:

Release artifacts are packaged with stable timestamps and sorted member lists,
and the release workflow compares the final archive member list against the
expected publish output before upload. If that validation fails, fix the
packaging step and re-run the failed release lane instead of uploading the
archive manually.

Each release archive also contains `MANIFEST.sha256`, generated from the
published payload before upload. `install.sh` verifies that manifest after
extraction for releases that require it, so do not remove or hand-edit it when
diagnosing release artifacts. Older explicit-version installs may not contain
the manifest and fall back to archive-level checksum verification.

```bash
git show "v1.17.0:version.json" | grep -q '"version": "1.17.0"' \
&& echo "version.json OK" \
Expand Down
53 changes: 51 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,63 @@ jobs:
run: |
mkdir -p artifacts
cd publish
tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" .
find . -exec touch -t 200001010000 {} +
if command -v sha256sum >/dev/null 2>&1; then
hash_file() { sha256sum "$1"; }
else
hash_file() { shasum -a 256 "$1"; }
fi
find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do
hash_file "$file"
done > .MANIFEST.sha256.tmp
mv .MANIFEST.sha256.tmp MANIFEST.sha256
touch -t 200001010000 MANIFEST.sha256
find . -type f | sed 's#^\./##' | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.members"
tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" -T "../artifacts/CodeIndex-${{ matrix.rid }}.members"
tar tzf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"
cmp "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"
rm "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"

- name: Archive release artifacts (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts
Compress-Archive -Path publish\* -DestinationPath "artifacts\CodeIndex-${{ matrix.rid }}.zip"
$fixedTimestamp = [DateTime]'2000-01-01T00:00:00Z'
Get-ChildItem publish -Recurse | ForEach-Object { $_.LastWriteTimeUtc = $fixedTimestamp }
$files = Get-ChildItem publish -File -Recurse |
Where-Object { $_.Name -ne 'MANIFEST.sha256' -and $_.Name -ne '.MANIFEST.sha256.tmp' } |
Sort-Object FullName
$manifestLines = foreach ($file in $files) {
$relative = [System.IO.Path]::GetRelativePath((Resolve-Path publish), $file.FullName).Replace('\', '/')
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()
"$hash $relative"
}
$manifestLines | Set-Content -NoNewline:$false -Encoding ascii publish\MANIFEST.sha256
(Get-Item publish\MANIFEST.sha256).LastWriteTimeUtc = $fixedTimestamp
$files = Get-ChildItem publish -File -Recurse | Sort-Object FullName
$relativeFiles = $files | ForEach-Object { [System.IO.Path]::GetRelativePath((Resolve-Path publish), $_.FullName) } | Sort-Object
Push-Location publish
try {
Compress-Archive -Path $relativeFiles -DestinationPath "..\artifacts\CodeIndex-${{ matrix.rid }}.zip"
} finally {
Pop-Location
}
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path "artifacts\CodeIndex-${{ matrix.rid }}.zip"))
try {
$actual = $zip.Entries | Where-Object { $_.Name } | ForEach-Object { $_.FullName } | Sort-Object
$expected = $relativeFiles | ForEach-Object { $_.Replace('\', '/') } | Sort-Object
if (@($actual).Count -ne @($expected).Count) {
throw "Archive member count mismatch: expected $(@($expected).Count), got $(@($actual).Count)"
}
for ($i = 0; $i -lt @($expected).Count; $i++) {
if (@($actual)[$i] -ne @($expected)[$i]) {
throw "Archive member mismatch at index ${i}: expected '$(@($expected)[$i])', got '$(@($actual)[$i])'"
}
}
} finally {
$zip.Dispose()
}

- name: Upload release artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand Down
15 changes: 15 additions & 0 deletions changelog.d/unreleased/2012.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
category: fixed
issues:
- 2012
affected:
- install.sh
---

## English

- **Installer reuse now requires a completed integrity marker (#2012)** — `install.sh` writes `integrity_ok` only after staging validated runtime assets, and existing installs without that marker are treated as incomplete.

## 日本語

- **installer の再利用判定が完了済み integrity marker を必須にしました (#2012)** — `install.sh` は検証済み runtime asset の staging 後にだけ `integrity_ok` を書き込み、この marker がない既存 install は未完了として扱います。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2040.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 2040
affected:
- .github/workflows/release.yml
- .codex/workflows/release-changelog.md
- install.sh
---

## English

- **Installer extraction now verifies per-file release payload checksums (#2040)** — release archives include `MANIFEST.sha256`, and `install.sh` refuses to install if any extracted payload file is missing or has a mismatched digest.

## 日本語

- **installer の展開処理が release payload のファイル別 checksum を検証するようになりました (#2040)** — release archive に `MANIFEST.sha256` を含め、`install.sh` は展開後の payload file が欠落または digest 不一致の場合に install を拒否します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2041.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2041
affected:
- .github/workflows/release.yml
- .codex/workflows/release-changelog.md
---

## English

- **Release archives now validate stable member lists (#2041)** — release packaging normalizes artifact timestamps, writes archive members in sorted order, and compares the final archive listing before upload.

## 日本語

- **リリースアーカイブの安定した member list を検証するようになりました (#2041)** — release packaging は成果物の timestamp を正規化し、archive member をソート順で書き込み、upload 前に最終的な archive listing を比較します。
157 changes: 136 additions & 21 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ set -euo pipefail
REPO="Widthdom/CodeIndex"
INSTALL_DIR="${CDIDX_INSTALL_DIR:-$HOME/.local/bin}"
BINARY_NAME="cdidx"
MANIFEST_REQUIRED_VERSION="1.24.6"
GITHUB_BASE_URL="${CDIDX_GITHUB_BASE_URL:-https://github.com}"
GITHUB_API_BASE_URL="${CDIDX_GITHUB_API_BASE_URL:-https://api.github.com}"
# Normalize optional base URL overrides by removing a trailing slash.
Expand Down Expand Up @@ -198,6 +199,29 @@ strip_version_prefix() {
printf '%s' "$1" | sed 's/^[^0-9]*//'
}

semver_core() {
printf '%s' "$1" | sed 's/^[^0-9]*//' | sed 's/[^0-9.].*$//'
}

semver_ge() {
local left right
left="$(semver_core "$1")"
right="$(semver_core "$2")"

awk -v left="$left" -v right="$right" '
BEGIN {
split(left, l, ".")
split(right, r, ".")
for (i = 1; i <= 3; i++) {
li = (l[i] == "" ? 0 : l[i]) + 0
ri = (r[i] == "" ? 0 : r[i]) + 0
if (li > ri) exit 0
if (li < ri) exit 1
}
exit 0
}'
}

extract_release_tag_name() {
local api_response="$1"
local version=""
Expand Down Expand Up @@ -490,6 +514,9 @@ existing_install_is_reusable() {
if [ ! -f "${INSTALL_DIR}/version.json" ]; then
return 1
fi
if ! grep -Eq '"integrity_ok"[[:space:]]*:[[:space:]]*true' "${INSTALL_DIR}/version.json"; then
return 1
fi

[ -f "${INSTALL_DIR}/LICENSE" ] || return 1
[ -f "${INSTALL_DIR}/COMMERCIAL_LICENSE.md" ] || return 1
Expand All @@ -510,6 +537,99 @@ existing_install_is_reusable() {
return 0
}

calculate_sha256() {
local path="$1"

if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
elif command -v shasum > /dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v openssl > /dev/null 2>&1; then
openssl dgst -sha256 "$path" | awk '{print $NF}'
else
error "No checksum tool found (need sha256sum, shasum, or openssl). Cannot verify release payload integrity."
fi
}

validate_archive_members() {
local archive="$1"
local member

tar tzf "$archive" | while IFS= read -r member || [ -n "$member" ]; do
case "$member" in
""|/*|..|../*|*/../*|*/.. )
error "Release archive contains unsafe member path before extraction: ${member:-<empty>}"
;;
esac
done
}

verify_payload_manifest() {
local extract_dir="$1"
local manifest="${extract_dir}/MANIFEST.sha256"
local manifest_paths line expected path actual extracted_paths

if [ ! -f "$manifest" ]; then
if semver_ge "${VERSION#v}" "$MANIFEST_REQUIRED_VERSION"; then
error "Release payload is missing MANIFEST.sha256. Refusing to install without per-file integrity metadata."
fi

warn "Release payload is missing MANIFEST.sha256; falling back to archive-level checksum verification for legacy release ${VERSION}."
return 0
fi

if ! manifest_paths="$(mktemp)"; then
error "Failed to create temporary manifest path list."
fi
if ! extracted_paths="$(mktemp)"; then
rm -f "$manifest_paths"
error "Failed to create temporary extracted path list."
fi

while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
expected="${line%% *}"
path="${line#* }"
case "$path" in
""|/*|*"/../"*|../*|*"/.." )
error "Invalid path in release payload manifest: ${path}"
;;
esac
printf '%s\n' "$path" >> "$manifest_paths"
if [ ! -f "${extract_dir}/${path}" ]; then
rm -f "$manifest_paths" "$extracted_paths"
error "Release payload manifest entry missing after extraction: ${path}"
fi
actual="$(calculate_sha256 "${extract_dir}/${path}")"
if [ "$actual" != "$expected" ]; then
rm -f "$manifest_paths" "$extracted_paths"
error "Release payload checksum mismatch for ${path}.\n Expected: ${expected}\n Actual: ${actual}"
fi
done < "$manifest"

(
cd "$extract_dir"
find . -type f ! -name MANIFEST.sha256 | sed 's#^\./##' | LC_ALL=C sort
) > "$extracted_paths"

while IFS= read -r path || [ -n "$path" ]; do
[ -n "$path" ] || continue
if ! grep -Fxq "$path" "$manifest_paths"; then
rm -f "$manifest_paths" "$extracted_paths"
error "Release payload contains file not listed in MANIFEST.sha256: ${path}"
fi
done < "$extracted_paths"

rm -f "$manifest_paths" "$extracted_paths"
}

write_integrity_version_json() {
local target="$1"
local version="${VERSION#v}"

printf '{"version":"%s","integrity_ok":true}\n' "$version" > "$target"
}

restore_backed_up_files() {
local backup_dir="$1"
local install_dir="$2"
Expand Down Expand Up @@ -555,7 +675,7 @@ promote_staged_install() {
local backed_up_files=""
local promoted_files=""

for asset in $required_files; do
for asset in ${BINARY_NAME} $required_assets; do
if [ -e "${install_dir}/${asset}" ]; then
if ! mv "${install_dir}/${asset}" "${backup_dir}/${asset}"; then
report_error "Failed to stage existing ${asset} into backup at ${backup_dir}. Install aborted before replacing the current install."
Expand Down Expand Up @@ -787,15 +907,7 @@ download_and_install() {
fi

local actual_checksum
if command -v sha256sum > /dev/null 2>&1; then
actual_checksum="$(sha256sum "${tmpdir}/${archive_name}" | awk '{print $1}')"
elif command -v shasum > /dev/null 2>&1; then
actual_checksum="$(shasum -a 256 "${tmpdir}/${archive_name}" | awk '{print $1}')"
elif command -v openssl > /dev/null 2>&1; then
actual_checksum="$(openssl dgst -sha256 "${tmpdir}/${archive_name}" | awk '{print $NF}')"
else
error "No checksum tool found (need sha256sum, shasum, or openssl). Cannot verify download integrity."
fi
actual_checksum="$(calculate_sha256 "${tmpdir}/${archive_name}")"

if [ "$actual_checksum" != "$expected_checksum" ]; then
error "Checksum mismatch!\n Expected: $expected_checksum\n Actual: $actual_checksum"
Expand All @@ -806,8 +918,12 @@ download_and_install() {
# 展開用サブディレクトリを使い、アーカイブや checksum ファイルと混在させない。
local extract_dir="${tmpdir}/extract"
mkdir -p "$extract_dir"
info "Checking archive member paths..."
validate_archive_members "${tmpdir}/${archive_name}"
info "Extracting..."
tar xzf "${tmpdir}/${archive_name}" -C "$extract_dir"
info "Verifying extracted payload..."
verify_payload_manifest "$extract_dir"

# Validate the extracted payload before copying anything into INSTALL_DIR.
# This avoids overwriting a healthy install with a partially broken one
Expand Down Expand Up @@ -892,6 +1008,7 @@ download_and_install() {
staged_assets="${staged_assets} ${asset}"
fi
done
write_integrity_version_json "${stage_dir}/version.json"
chmod +x "${stage_dir}/${BINARY_NAME}"

local backup_dir
Expand Down Expand Up @@ -1054,23 +1171,21 @@ echo "mock ${BINARY_NAME} (${rehearsal_version}) for local mirror self-test" >&2
exit 2
EOF
chmod +x "${local_payload_dir}/${BINARY_NAME}"
printf '{"version":"%s"}\n' "$rehearsal_version_no_prefix" > "${local_payload_dir}/version.json"
printf '{"version":"%s","integrity_ok":true}\n' "$rehearsal_version_no_prefix" > "${local_payload_dir}/version.json"
: > "${local_payload_dir}/${runtime_asset}"

(
cd "$local_payload_dir"
tar czf "../${archive_name}" "${BINARY_NAME}" version.json "${runtime_asset}"
{
calculate_sha256 "${BINARY_NAME}" | awk -v file="${BINARY_NAME}" '{ print $1 " " file }'
calculate_sha256 version.json | awk '{ print $1 " version.json" }'
calculate_sha256 "${runtime_asset}" | awk -v file="${runtime_asset}" '{ print $1 " " file }'
} > .MANIFEST.sha256.tmp
mv .MANIFEST.sha256.tmp MANIFEST.sha256
tar czf "../${archive_name}" MANIFEST.sha256 "${BINARY_NAME}" version.json "${runtime_asset}"
)

if command -v sha256sum > /dev/null 2>&1; then
checksum="$(sha256sum "${local_release_base}/${archive_name}" | awk '{print $1}')"
elif command -v shasum > /dev/null 2>&1; then
checksum="$(shasum -a 256 "${local_release_base}/${archive_name}" | awk '{print $1}')"
elif command -v openssl > /dev/null 2>&1; then
checksum="$(openssl dgst -sha256 "${local_release_base}/${archive_name}" | awk '{print $NF}')"
else
error "No checksum tool found (need sha256sum, shasum, or openssl) for local mirror self-test."
fi
checksum="$(calculate_sha256 "${local_release_base}/${archive_name}")"
printf '%s %s\n' "$checksum" "$archive_name" > "${local_release_base}/sha256sums.txt"

if has_explicit_self_test_install_dir; then
Expand Down
Loading
Loading