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/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 5089f88b54..0c42080e4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,59 @@ 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: + 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 + timeout-minutes: 90 + permissions: + contents: read strategy: matrix: # Keep this matrix in sync with docs/platform-support.md and install.sh @@ -52,7 +98,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 +520,18 @@ jobs: create-release: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: release + needs: [preflight, release] + timeout-minutes: 45 + permissions: + contents: write + id-token: write + attestations: write 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 @@ -554,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 @@ -588,7 +639,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 +667,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 +703,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 @@ -734,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 @@ -757,7 +808,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 +871,18 @@ jobs: publish-nuget: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: release + needs: [preflight, release] + timeout-minutes: 30 + permissions: + contents: read + id-token: write + attestations: write 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 +893,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 @@ -859,7 +915,13 @@ 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")" + 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 @@ -867,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 @@ -936,21 +998,35 @@ 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' runs-on: ubuntu-latest - needs: create-release + needs: [preflight, create-release] + timeout-minutes: 90 permissions: contents: read id-token: write @@ -960,13 +1036,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 @@ -990,17 +1066,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 @@ -1012,20 +1088,23 @@ jobs: publish-homebrew: if: github.repository == 'Widthdom/CodeIndex' runs-on: ubuntu-latest - needs: create-release + needs: [preflight, create-release] + timeout-minutes: 30 + permissions: + contents: read environment: homebrew-production steps: - name: Checkout release tag - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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 @@ -1034,6 +1113,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 }} @@ -1048,20 +1132,32 @@ 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)" - curl -fsSL "$CHECKSUMS_URL" -o "$checksums" + artifact_root="homebrew-artifacts" + tap_dir="" + cleanup() { + if [ -n "$tap_dir" ]; then + rm -rf "$tap_dir" + fi + } + trap cleanup EXIT - 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)" - 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" <