From fb3b1f20a51b82c554c919cc0f5857de24428fc3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:16:54 +0900 Subject: [PATCH 1/8] Validate release tag before privileged checkout (#3485) --- .github/workflows/release.yml | 72 +++++++++++++++++++------ changelog.d/unreleased/3485.security.md | 15 ++++++ 2 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/3485.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5089f88b54..f6eeb45a08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,8 +22,48 @@ permissions: attestations: write jobs: + preflight: + name: Validate release tag + if: github.repository == 'Widthdom/CodeIndex' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + ref: ${{ steps.release.outputs.ref }} + steps: + - name: Validate release tag + id: release + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG_NAME: ${{ inputs.tag_name }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG_NAME" + else + if [ "$REF_TYPE" != "tag" ]; then + echo "Release workflow must run from a tag ref, got ${REF_TYPE}:${REF_NAME}." >&2 + exit 1 + fi + tag="$REF_NAME" + fi + + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a v-prefixed SemVer version, got: ${tag}" >&2 + exit 1 + fi + + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + echo "ref=refs/tags/${tag}" >> "$GITHUB_OUTPUT" + release: if: github.repository == 'Widthdom/CodeIndex' + needs: preflight strategy: matrix: # Keep this matrix in sync with docs/platform-support.md and install.sh @@ -52,7 +92,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: ${{ inputs.tag_name || github.ref }} + ref: ${{ needs.preflight.outputs.ref }} # The release workflow runs the same Windows test suite, so it needs the # same temp-root pinning as .github/workflows/dotnet.yml: even after the @@ -474,13 +514,13 @@ jobs: create-release: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: release + needs: [preflight, release] environment: release-production steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.tag_name || github.ref }} + ref: ${{ needs.preflight.outputs.ref }} - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -588,7 +628,7 @@ jobs: if: ${{ !inputs.allow_generated_notes }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ needs.preflight.outputs.tag }} run: | set -euo pipefail version="${TAG_NAME#v}" @@ -616,7 +656,7 @@ jobs: - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ needs.preflight.outputs.tag }} USE_GENERATED_NOTES: ${{ inputs.allow_generated_notes || false }} run: | set -euo pipefail @@ -652,7 +692,7 @@ jobs: - name: Wait for release assets to be downloadable env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ needs.preflight.outputs.tag }} # install.sh downloads BOTH the linux-x64 tarball and sha256sums.txt, # and the CDN can propagate each release asset independently. Polling # only the tarball let the verify step run while sha256sums.txt was @@ -757,7 +797,7 @@ jobs: - name: Verify install.sh against the published release env: - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ needs.preflight.outputs.tag }} run: | set -euo pipefail export CDIDX_INSTALL_DIR="$(mktemp -d)/bin" @@ -820,13 +860,13 @@ jobs: publish-nuget: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: release + needs: [preflight, release] environment: nuget-production steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.tag_name || github.ref }} + ref: ${{ needs.preflight.outputs.ref }} - name: Set up .NET uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 @@ -837,7 +877,7 @@ jobs: id: version run: | set -euo pipefail - TAG="${{ inputs.tag_name || github.ref_name }}" + TAG="${{ needs.preflight.outputs.tag }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Release tag must be a v-prefixed SemVer version, got: ${TAG}" >&2 exit 1 @@ -950,7 +990,7 @@ jobs: publish-container: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: create-release + needs: [preflight, create-release] permissions: contents: read id-token: write @@ -960,13 +1000,13 @@ jobs: - name: Checkout release tag uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ inputs.tag_name || github.ref }} + ref: ${{ needs.preflight.outputs.ref }} - name: Extract version from tag id: version run: | set -euo pipefail - TAG="${{ inputs.tag_name || github.ref_name }}" + TAG="${{ needs.preflight.outputs.tag }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Release tag must be a v-prefixed SemVer version, got: ${TAG}" >&2 exit 1 @@ -1012,20 +1052,20 @@ jobs: publish-homebrew: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: create-release + needs: [preflight, create-release] environment: homebrew-production steps: - name: Checkout release tag uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.tag_name || github.ref }} + ref: ${{ needs.preflight.outputs.ref }} - name: Extract release tag id: release run: | set -euo pipefail - TAG="${{ inputs.tag_name || github.ref_name }}" + TAG="${{ needs.preflight.outputs.tag }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Release tag must be a v-prefixed SemVer version, got: ${TAG}" >&2 exit 1 diff --git a/changelog.d/unreleased/3485.security.md b/changelog.d/unreleased/3485.security.md new file mode 100644 index 0000000000..3ddd460fd4 --- /dev/null +++ b/changelog.d/unreleased/3485.security.md @@ -0,0 +1,15 @@ +--- +category: security +issues: + - 3485 +affected: + - .github/workflows/release.yml +--- + +## English + +- **Release dispatch tags are validated before privileged checkouts (#3485)** — manual release inputs now pass through a low-privilege preflight job that normalizes the release tag/ref before release, NuGet, container, or Homebrew jobs check out code. + +## 日本語 + +- **release dispatch の tag を privileged checkout 前に検証するようになりました (#3485)** — 手動リリース入力は低権限の preflight job で release tag/ref を正規化してから、release / NuGet / container / Homebrew の各 job が checkout するようになりました。 From 619fd567cc24e3373e487bd25ec3ce6b95e85167 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:19:47 +0900 Subject: [PATCH 2/8] Scope release workflow permissions and runtime bounds (#3486) --- .github/workflows/dotnet.yml | 1 + .github/workflows/release.yml | 30 ++++++++++++++++++++----- changelog.d/unreleased/3486.security.md | 16 +++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3486.security.md diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a3e814572c..fcd00697fd 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -27,6 +27,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] test-framework: [net8.0, net9.0] runs-on: ${{ matrix.os }} + timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6eeb45a08..7ae7b7ce89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,15 +17,18 @@ on: type: boolean permissions: - contents: write - id-token: write - attestations: write + contents: read + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref }} + cancel-in-progress: false jobs: preflight: name: Validate release tag if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read outputs: @@ -64,6 +67,9 @@ jobs: release: if: github.repository == 'Widthdom/CodeIndex' needs: preflight + timeout-minutes: 90 + permissions: + contents: read strategy: matrix: # Keep this matrix in sync with docs/platform-support.md and install.sh @@ -515,6 +521,11 @@ jobs: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest needs: [preflight, release] + timeout-minutes: 45 + permissions: + contents: write + id-token: write + attestations: write environment: release-production steps: - name: Checkout @@ -774,7 +785,7 @@ jobs: deadline=$((SECONDS + wait_seconds)) attempt=1 while :; do - code="$(curl -fsSL --range 0-0 -o /dev/null -w '%{http_code}' "$url" || true)" + code="$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors --range 0-0 -o /dev/null -w '%{http_code}' "$url" || true)" if [ "$code" = "200" ] || [ "$code" = "206" ]; then echo "Asset ${asset} reachable after $attempt attempt(s)." reachable=1 @@ -861,6 +872,9 @@ jobs: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest needs: [preflight, release] + timeout-minutes: 30 + permissions: + contents: read environment: nuget-production steps: - name: Checkout @@ -899,7 +913,7 @@ jobs: run: | set -euo pipefail package_url="https://api.nuget.org/v3-flatcontainer/cdidx/${VERSION}/cdidx.${VERSION}.nupkg" - status="$(curl -sS -o /tmp/cdidx-nuget-head -w '%{http_code}' -I "$package_url")" + status="$(curl -sS --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-all-errors -o /tmp/cdidx-nuget-head -w '%{http_code}' -I "$package_url")" if [ "$status" = "200" ]; then echo "NuGet package cdidx ${VERSION} is already published; refusing to mask this as a duplicate." >&2 exit 1 @@ -991,6 +1005,7 @@ jobs: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest needs: [preflight, create-release] + timeout-minutes: 90 permissions: contents: read id-token: write @@ -1053,6 +1068,9 @@ jobs: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest needs: [preflight, create-release] + timeout-minutes: 30 + permissions: + contents: read environment: homebrew-production steps: - name: Checkout release tag @@ -1090,7 +1108,7 @@ jobs: RELEASE_URL="${REPO_URL}/releases/download/${TAG_NAME}" CHECKSUMS_URL="${RELEASE_URL}/sha256sums.txt" checksums="$(mktemp)" - curl -fsSL "$CHECKSUMS_URL" -o "$checksums" + curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors "$CHECKSUMS_URL" -o "$checksums" sha_for() { awk -v name="$1" '$2 == name { print $1; found=1 } END { exit found ? 0 : 1 }' "$checksums" diff --git a/changelog.d/unreleased/3486.security.md b/changelog.d/unreleased/3486.security.md new file mode 100644 index 0000000000..7ab4dabb55 --- /dev/null +++ b/changelog.d/unreleased/3486.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3486 +affected: + - .github/workflows/release.yml + - .github/workflows/dotnet.yml +--- + +## English + +- **Release and build workflows now declare privilege and runtime bounds (#3486)** — release permissions are scoped per job, release runs use a tag/ref concurrency group, release/build jobs have explicit timeouts, and release curl probes have connection and total-time limits. + +## 日本語 + +- **release / build workflow が権限と実行時間の境界を明示するようになりました (#3486)** — release 権限は job 単位に絞り、release 実行は tag/ref ごとの concurrency group を使い、release / build job には明示 timeout を設定し、release の curl probe には接続時間と総実行時間の上限を追加しました。 From 7c278b9bbcfedfd70449d467ad4fb12e6ccd7491 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:22:55 +0900 Subject: [PATCH 3/8] Pin release and mutation workflow actions (#3487) --- .github/workflows/mutation-testing.yml | 4 ++-- .github/workflows/release.yml | 10 +++++----- changelog.d/unreleased/3487.security.md | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3487.security.md diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 397d30d3d6..a7619745ae 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: | 8.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae7b7ce89..ef836c5251 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -605,7 +605,7 @@ jobs: test -s release-files/sha256sums.txt.asc - name: Attest release artifacts - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 with: subject-path: | release-files/*.tar.gz @@ -1045,17 +1045,17 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push container image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: linux/amd64,linux/arm64 @@ -1074,7 +1074,7 @@ jobs: environment: homebrew-production steps: - name: Checkout release tag - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 ref: ${{ needs.preflight.outputs.ref }} diff --git a/changelog.d/unreleased/3487.security.md b/changelog.d/unreleased/3487.security.md new file mode 100644 index 0000000000..3543a91ad7 --- /dev/null +++ b/changelog.d/unreleased/3487.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3487 +affected: + - .github/workflows/release.yml + - .github/workflows/mutation-testing.yml +--- + +## English + +- **Release and mutation workflow actions are pinned by commit SHA (#3487)** — remaining mutable Docker, attestation, checkout, and setup-dotnet action tags now use reviewed commit SHAs with version comments. + +## 日本語 + +- **release / mutation workflow のactionをcommit SHAで固定しました (#3487)** — 残っていた可変の Docker / attestation / checkout / setup-dotnet action tag を、確認済みcommit SHAとversion comment付きの参照へ置き換えました。 From f559579e19273a71cf52881be35799631790933c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:26:33 +0900 Subject: [PATCH 4/8] Use trusted publishing for NuGet release artifacts (#3489) --- .github/workflows/release.yml | 23 +++++++++++++++++++---- changelog.d/unreleased/3489.security.md | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3489.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef836c5251..2cd67fd79f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -875,6 +875,8 @@ jobs: timeout-minutes: 30 permissions: contents: read + id-token: write + attestations: write environment: nuget-production steps: - name: Checkout @@ -990,16 +992,29 @@ jobs: exit 1 fi + - name: Attest NuGet package artifacts + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 + with: + subject-path: | + nupkg/*.nupkg + nupkg/*.snupkg + + - name: NuGet trusted publishing login + id: nuget-login + uses: NuGet/login@ebc737b6fc418a6ca0073cf116ec8dc156d8b81e # v1 + with: + user: Widthdom + - name: Publish to NuGet run: | set -euo pipefail dotnet nuget push nupkg/*.nupkg \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --source https://api.nuget.org/v3/index.json \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ + --source https://www.nuget.org/api/v2/package \ --no-symbols dotnet nuget push nupkg/*.snupkg \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --source https://api.nuget.org/v3/index.json + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ + --source https://www.nuget.org/api/v2/package publish-container: if: github.repository == 'Widthdom/CodeIndex' diff --git a/changelog.d/unreleased/3489.security.md b/changelog.d/unreleased/3489.security.md new file mode 100644 index 0000000000..8c3b1e6a47 --- /dev/null +++ b/changelog.d/unreleased/3489.security.md @@ -0,0 +1,15 @@ +--- +category: security +issues: + - 3489 +affected: + - .github/workflows/release.yml +--- + +## English + +- **NuGet publishing now uses OIDC trusted publishing and package attestation (#3489)** — the NuGet release job exchanges GitHub OIDC for a short-lived NuGet API key and attests both `.nupkg` and `.snupkg` artifacts before publishing. + +## 日本語 + +- **NuGet publishing が OIDC trusted publishing と package attestation を使うようになりました (#3489)** — NuGet release job は GitHub OIDC を短命 NuGet API key に交換し、公開前に `.nupkg` と `.snupkg` の両方を attest するようになりました。 From b0e5d15ead0b3b8cae33a8fa0876c19444da410a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:30:13 +0900 Subject: [PATCH 5/8] Harden Homebrew tap publishing credentials (#3490) --- .github/workflows/release.yml | 14 ++++++++++++-- changelog.d/unreleased/3490.security.md | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3490.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cd67fd79f..fcd78ee92a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1123,6 +1123,15 @@ jobs: RELEASE_URL="${REPO_URL}/releases/download/${TAG_NAME}" CHECKSUMS_URL="${RELEASE_URL}/sha256sums.txt" checksums="$(mktemp)" + tap_dir="" + cleanup() { + rm -f "$checksums" + if [ -n "$tap_dir" ]; then + rm -rf "$tap_dir" + fi + } + trap cleanup EXIT + curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors "$CHECKSUMS_URL" -o "$checksums" sha_for() { @@ -1134,7 +1143,8 @@ jobs: osx_arm64_sha="$(sha_for CodeIndex-osx-arm64.tar.gz)" tap_dir="$(mktemp -d)" - git clone "https://x-access-token:${TAP_TOKEN}@github.com/Widthdom/homebrew-tap.git" "$tap_dir" + credential_helper='!f() { echo username=x-access-token; echo "password=${TAP_TOKEN}"; }; f' + git -c credential.helper="$credential_helper" clone "https://github.com/Widthdom/homebrew-tap.git" "$tap_dir" mkdir -p "$tap_dir/Formula" cat > "$tap_dir/Formula/codeindex.rb" < Date: Fri, 12 Jun 2026 09:33:14 +0900 Subject: [PATCH 6/8] Use workflow artifacts for Homebrew checksums (#3491) --- .github/workflows/release.yml | 27 ++++++++++++++++--------- changelog.d/unreleased/3491.security.md | 15 ++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/3491.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcd78ee92a..3e26a0c758 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1107,6 +1107,11 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Download release artifacts for checksum calculation + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: homebrew-artifacts + - name: Bump Homebrew formula env: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} @@ -1121,26 +1126,28 @@ jobs: VERSION="${TAG_NAME#v}" REPO_URL="https://github.com/${GITHUB_REPOSITORY}" RELEASE_URL="${REPO_URL}/releases/download/${TAG_NAME}" - CHECKSUMS_URL="${RELEASE_URL}/sha256sums.txt" - checksums="$(mktemp)" + artifact_root="homebrew-artifacts" tap_dir="" cleanup() { - rm -f "$checksums" if [ -n "$tap_dir" ]; then rm -rf "$tap_dir" fi } trap cleanup EXIT - curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors "$CHECKSUMS_URL" -o "$checksums" - - sha_for() { - awk -v name="$1" '$2 == name { print $1; found=1 } END { exit found ? 0 : 1 }' "$checksums" + sha_for_artifact() { + asset="$1" + path="$(find "$artifact_root" -type f -name "$asset" -print -quit)" + if [ -z "$path" ]; then + echo "Release artifact ${asset} was not found under ${artifact_root}." >&2 + exit 1 + fi + sha256sum "$path" | awk '{ print $1 }' } - linux_x64_sha="$(sha_for CodeIndex-linux-x64.tar.gz)" - linux_arm64_sha="$(sha_for CodeIndex-linux-arm64.tar.gz)" - osx_arm64_sha="$(sha_for CodeIndex-osx-arm64.tar.gz)" + linux_x64_sha="$(sha_for_artifact CodeIndex-linux-x64.tar.gz)" + linux_arm64_sha="$(sha_for_artifact CodeIndex-linux-arm64.tar.gz)" + osx_arm64_sha="$(sha_for_artifact CodeIndex-osx-arm64.tar.gz)" tap_dir="$(mktemp -d)" credential_helper='!f() { echo username=x-access-token; echo "password=${TAP_TOKEN}"; }; f' diff --git a/changelog.d/unreleased/3491.security.md b/changelog.d/unreleased/3491.security.md new file mode 100644 index 0000000000..2dc545714f --- /dev/null +++ b/changelog.d/unreleased/3491.security.md @@ -0,0 +1,15 @@ +--- +category: security +issues: + - 3491 +affected: + - .github/workflows/release.yml +--- + +## English + +- **Homebrew formula checksums now come from workflow-owned artifacts (#3491)** — the tap update job downloads this workflow run's release artifacts and computes formula SHA-256 values locally instead of re-downloading the public release checksum manifest. + +## 日本語 + +- **Homebrew formula のchecksumをworkflow所有artifactから計算するようになりました (#3491)** — tap 更新jobは同一workflow runのrelease artifactをdownloadし、public releaseのchecksum manifestを再downloadせずにformula用SHA-256をローカル計算するようになりました。 From d9b342cc9a0aa28c790a18bdc5c45a3c2ca860be Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:35:48 +0900 Subject: [PATCH 7/8] Use private NuGet preflight response file (#3508) --- .github/workflows/release.yml | 10 ++++++++-- changelog.d/unreleased/3508.security.md | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3508.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e26a0c758..0c42080e4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -915,7 +915,13 @@ jobs: run: | set -euo pipefail package_url="https://api.nuget.org/v3-flatcontainer/cdidx/${VERSION}/cdidx.${VERSION}.nupkg" - status="$(curl -sS --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-all-errors -o /tmp/cdidx-nuget-head -w '%{http_code}' -I "$package_url")" + response_headers="$(mktemp "${RUNNER_TEMP:-/tmp}/cdidx-nuget-head.XXXXXX")" + cleanup() { + rm -f "$response_headers" + } + trap cleanup EXIT + + status="$(curl -sS --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-all-errors -o "$response_headers" -w '%{http_code}' -I "$package_url")" if [ "$status" = "200" ]; then echo "NuGet package cdidx ${VERSION} is already published; refusing to mask this as a duplicate." >&2 exit 1 @@ -923,7 +929,7 @@ jobs: if [ "$status" != "404" ]; then echo "Could not verify NuGet package availability for cdidx ${VERSION}; HTTP ${status}." >&2 - cat /tmp/cdidx-nuget-head >&2 || true + cat "$response_headers" >&2 || true exit 1 fi diff --git a/changelog.d/unreleased/3508.security.md b/changelog.d/unreleased/3508.security.md new file mode 100644 index 0000000000..738f501afa --- /dev/null +++ b/changelog.d/unreleased/3508.security.md @@ -0,0 +1,15 @@ +--- +category: security +issues: + - 3508 +affected: + - .github/workflows/release.yml +--- + +## English + +- **NuGet publish preflight now uses a private temporary response file (#3508)** — the release job writes NuGet availability probe headers to a `mktemp` file under the job temp root and removes it on exit instead of using a fixed `/tmp` path. + +## 日本語 + +- **NuGet publish preflight がprivateな一時response fileを使うようになりました (#3508)** — release job はNuGet公開可否probeのheadersを固定 `/tmp` path ではなくjob temp root配下の `mktemp` fileへ書き込み、終了時に削除するようになりました。 From cfea8107eee1fe0d005bf3caefcc580c4de388e5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:52:58 +0900 Subject: [PATCH 8/8] Update release workflow security tests (#3485 #3486 #3487 #3489 #3490 #3491 #3508) --- tests/CodeIndex.Tests/ReleaseWorkflowTests.cs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index cb379223cc..78b4927e30 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -49,6 +49,14 @@ public void ReleaseWorkflow_HomebrewFormulaInstallsNativeSqliteAssetAndTouchesSq { var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "release.yml")); + Assert.Contains("Download release artifacts for checksum calculation", workflow); + Assert.Contains("actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1", workflow); + Assert.Contains("sha_for_artifact()", workflow); + Assert.Contains("find \"$artifact_root\" -type f -name \"$asset\" -print -quit", workflow); + Assert.DoesNotContain("CHECKSUMS_URL", workflow); + Assert.DoesNotContain("https://x-access-token:${TAP_TOKEN}@github.com/Widthdom/homebrew-tap.git", workflow); + Assert.Contains("credential_helper='!f() { echo username=x-access-token; echo \"password=${TAP_TOKEN}\"; }; f'", workflow); + Assert.Contains("trap cleanup EXIT", workflow); Assert.Contains("native_sqlite_asset = OS.mac? ? \"libe_sqlite3.dylib\" : \"libe_sqlite3.so\"", workflow); Assert.Contains("bin.install native_sqlite_asset", workflow); Assert.Contains("assert_predicate bin/native_sqlite_asset, :exist?", workflow); @@ -112,11 +120,35 @@ public void ReleaseWorkflow_ValidatesNuGetVersionBeforePublishing() Assert.Contains("jq -r '.version // empty' version.json", workflow); Assert.Contains("does not match release tag", workflow); Assert.Contains("https://api.nuget.org/v3-flatcontainer/cdidx/${VERSION}/cdidx.${VERSION}.nupkg", workflow); + Assert.Contains("response_headers=\"$(mktemp \"${RUNNER_TEMP:-/tmp}/cdidx-nuget-head.XXXXXX\")\"", workflow); + Assert.Contains("cat \"$response_headers\"", workflow); + Assert.DoesNotContain("/tmp/cdidx-nuget-head", workflow); Assert.Contains("NuGet package cdidx ${VERSION} is already published", workflow); Assert.Contains("Expected packed package ${expected_package} was not produced", workflow); + Assert.Contains("Attest NuGet package artifacts", workflow); + Assert.Contains("nupkg/*.nupkg", workflow); + Assert.Contains("nupkg/*.snupkg", workflow); + Assert.Contains("NuGet/login@ebc737b6fc418a6ca0073cf116ec8dc156d8b81e # v1", workflow); + Assert.Contains("steps.nuget-login.outputs.NUGET_API_KEY", workflow); + Assert.DoesNotContain("secrets.NUGET_API_KEY", workflow); Assert.DoesNotContain("--skip-duplicate", workflow); } + [Fact] + public void ReleaseWorkflow_ValidatesReleaseTagBeforePrivilegedJobs() + { + var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "release.yml")); + + Assert.Contains("preflight:", workflow); + Assert.Contains("name: Validate release tag", workflow); + Assert.Contains("permissions:\n contents: read", workflow); + Assert.Contains("ref=refs/tags/${tag}", workflow); + Assert.Contains("ref: ${{ needs.preflight.outputs.ref }}", workflow); + Assert.Contains("needs: [preflight, release]", workflow); + Assert.Contains("needs: [preflight, create-release]", workflow); + Assert.DoesNotContain("ref: ${{ inputs.tag_name || github.ref }}", workflow); + } + [Fact] public void ReleaseWorkflow_UsesChangelogToolForTemplatedReleaseNotes() { @@ -362,10 +394,11 @@ public void ReleaseWorkflow_PublishesOfficialContainerImage() var dockerfile = File.ReadAllText(Path.Combine(root, "Dockerfile")); Assert.Contains("publish-container:", workflow); - Assert.Contains("needs: create-release", workflow); + Assert.Contains("needs: [preflight, create-release]", workflow); Assert.Contains("packages: write", workflow); - Assert.Contains("docker/login-action@v3", workflow); - Assert.Contains("docker/build-push-action@v6", workflow); + Assert.Contains("docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4", workflow); + Assert.Contains("docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3", workflow); + Assert.Contains("docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6", workflow); Assert.Contains("platforms: linux/amd64,linux/arm64", workflow); Assert.Contains("ghcr.io/widthdom/codeindex:${version}", workflow); Assert.Contains("ghcr.io/widthdom/codeindex:latest", workflow); @@ -377,6 +410,17 @@ public void ReleaseWorkflow_PublishesOfficialContainerImage() Assert.Contains("ENTRYPOINT [\"cdidx\"]", dockerfile); } + [Fact] + public void MutationWorkflow_PinsActionsByCommitSha() + { + var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "mutation-testing.yml")); + + Assert.Contains("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", workflow); + Assert.Contains("actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0", workflow); + Assert.DoesNotContain("actions/checkout@v6", workflow); + Assert.DoesNotContain("actions/setup-dotnet@v5", workflow); + } + private static string GetRepositoryRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory);