From 7a5192481c271182edaaa773af273121e51f3c68 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 11 Sep 2026 04:29:34 -0600 Subject: [PATCH 1/6] Fix StoryExpireRemoteCacheTest --- tests/Feature/StoryExpireRemoteCacheTest.php | 30 +++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/Feature/StoryExpireRemoteCacheTest.php b/tests/Feature/StoryExpireRemoteCacheTest.php index 84e5259e1..bdb2e8688 100644 --- a/tests/Feature/StoryExpireRemoteCacheTest.php +++ b/tests/Feature/StoryExpireRemoteCacheTest.php @@ -4,9 +4,11 @@ use App\Jobs\StoryPipeline\StoryExpire; use App\Models\Profile; use App\Models\Story; use App\Models\User; +use App\Services\StoryIndexService; use App\Services\StoryService; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Redis; uses(LazilyRefreshDatabase::class); @@ -38,24 +40,38 @@ function makeRemoteStory(Profile $author): Story return $story; } -it('invalidates the author latest cache when a remote story expires', function () { - $user = User::factory()->create(); +it('invalidates both story caches when a remote story expires', function () { $author = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']); - $story = makeRemoteStory($author); - $cacheKey = StoryService::STORY_KEY.'latest:pid-'.$author->id; + $story->forceFill([ + 'active' => true, + 'expires_at' => now()->addHours(24), + ])->save(); + $index = app(StoryIndexService::class); + $cacheKey = StoryService::STORY_KEY . 'latest:pid-' . $author->id; - // Warm the cache. + // Index is empty, so this warms the SQL-backed cache. expect(StoryService::latest($author->id))->toBe($story->id); expect(Cache::has($cacheKey))->toBeTrue(); + // Now index it; latest() answers from Redis from here on. + $index->indexStory($story); + expect($index->hasActiveStory($author->id))->toBeTrue(); + expect(StoryService::latest($author->id))->toBe($story->id); + + $this->travel(25)->hours(); (new StoryExpire($story))->handle(); - // The row is gone and the cache no longer points at the deleted id. + // Row gone, SQL cache cleared. expect(Story::find($story->id))->toBeNull(); expect(Cache::has($cacheKey))->toBeFalse(); - // Re-resolving now returns null (author has no stories) without crashing. + // Index actually pruned, not just outside the 24h window. + expect(Redis::exists("story:{$story->id}"))->toBeFalsy(); + expect(Redis::sismember('story:active_authors', (string) $author->id))->toBeFalsy(); + expect($index->hasActiveStory($author->id))->toBeFalse(); + + // Re-resolving returns null without crashing. expect(StoryService::latest($author->id))->toBeNull(); }); From 9357c1117f2f55fa398af75c787027fdf3564e5e Mon Sep 17 00:00:00 2001 From: Shlee Date: Fri, 11 Sep 2026 20:09:03 +0930 Subject: [PATCH 2/6] Update docker-push.yml --- .github/workflows/docker-push.yml | 94 +++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index 5d0264b99..9c132575f 100644 --- a/.github/workflows/docker-push.yml +++ b/.github/workflows/docker-push.yml @@ -20,15 +20,75 @@ env: IMAGE_NAME: ghcr.io/${{ github.repository }} jobs: - build-and-push: - runs-on: ubuntu-latest - timeout-minutes: 60 + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runs-on: ubuntu-latest + - platform: linux/arm64 + runs-on: ubuntu-24.04-arm + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare platform tag + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}" >> "$GITHUB_OUTPUT" + + # Push each arch to the registry by digest only — no human-readable tag + # yet. Digests get stitched into one multi-arch manifest by the merge + # job below, which is what carries the real tags (latest, sha, etc). + - name: Build and push (${{ matrix.platform }}) + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ github.ref_name }}-${{ steps.platform.outputs.pair }} + cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ steps.platform.outputs.pair }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -49,15 +109,15 @@ jobs: type=raw,value=${{ github.ref_name == 'dev' && 'latest' || github.ref_name }} type=sha,format=short,prefix=${{ github.ref_name == 'dev' && 'latest' || github.ref_name }}- - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ github.ref_name }} - cache-to: type=gha,mode=max,scope=${{ github.ref_name }} - sbom: true - provenance: true + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} From 503c86e147e85cd6a948f5e4d716e3c0b12a567f Mon Sep 17 00:00:00 2001 From: Shlee Date: Fri, 11 Sep 2026 20:10:17 +0930 Subject: [PATCH 3/6] Update docker-tag.yml --- .github/workflows/docker-tag.yml | 115 ++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker-tag.yml b/.github/workflows/docker-tag.yml index 734619731..1d633d507 100644 --- a/.github/workflows/docker-tag.yml +++ b/.github/workflows/docker-tag.yml @@ -19,15 +19,13 @@ env: IMAGE_NAME: ghcr.io/${{ github.repository }} jobs: - build-and-push: + prepare: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 5 + outputs: + tag_name: ${{ steps.latest.outputs.tag_name }} + is_latest: ${{ steps.latest.outputs.is_latest }} steps: - - name: Checkout tag - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - name: Determine if this tag is the current latest release id: latest env: @@ -46,8 +44,78 @@ jobs: fi echo "Tag: $TAG_NAME | Latest published release: ${LATEST:-} | is_latest=$( [ "$TAG_NAME" = "$LATEST" ] && echo true || echo false )" - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + build: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runs-on: ubuntu-latest + - platform: linux/arm64 + runs-on: ubuntu-24.04-arm + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 45 + steps: + - name: Checkout tag + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare platform tag + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}" >> "$GITHUB_OUTPUT" + + # Push each arch to the registry by digest only — no human-readable tag + # yet. Digests get stitched into one multi-arch manifest by the merge + # job below, which is what carries the real tags (v1.2.3, stable, etc). + - name: Build and push (${{ matrix.platform }}) + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=release-${{ steps.platform.outputs.pair }} + cache-to: type=gha,mode=max,scope=release-${{ steps.platform.outputs.pair }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: [prepare, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -66,28 +134,27 @@ jobs: # newline-separated entries, not space-separated. { echo "tags<> "$GITHUB_OUTPUT" - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.tags.outputs.tags }} - cache-from: type=gha,scope=release - cache-to: type=gha,mode=max,scope=release - sbom: true - provenance: true + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(echo "${{ steps.tags.outputs.tags }}" | sed 's/^/-t /' | tr '\n' ' ') \ + $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.tag_name }} - name: Summary run: | echo "### Release image build" >> "$GITHUB_STEP_SUMMARY" - echo "- Tag pushed: \`${{ steps.latest.outputs.tag_name }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Also tagged 'stable': \`${{ steps.latest.outputs.is_latest }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Tag pushed: \`${{ needs.prepare.outputs.tag_name }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Also tagged 'stable': \`${{ needs.prepare.outputs.is_latest }}\`" >> "$GITHUB_STEP_SUMMARY" echo "- Platforms: linux/amd64, linux/arm64" >> "$GITHUB_STEP_SUMMARY" From fdcdc62291cf1e5ec6640bbfd7b77ec0ef306870 Mon Sep 17 00:00:00 2001 From: Shlee Date: Fri, 11 Sep 2026 20:10:45 +0930 Subject: [PATCH 4/6] Update Ubuntu version in Docker workflow --- .github/workflows/docker-tag.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-tag.yml b/.github/workflows/docker-tag.yml index 1d633d507..20ceba278 100644 --- a/.github/workflows/docker-tag.yml +++ b/.github/workflows/docker-tag.yml @@ -51,9 +51,9 @@ jobs: matrix: include: - platform: linux/amd64 - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 - platform: linux/arm64 - runs-on: ubuntu-24.04-arm + runs-on: ubuntu-26.04-arm runs-on: ${{ matrix.runs-on }} timeout-minutes: 45 steps: From 6fcc518720001690efcbcac60e38953bb2dc07db Mon Sep 17 00:00:00 2001 From: Shlee Date: Fri, 11 Sep 2026 20:11:05 +0930 Subject: [PATCH 5/6] Update docker-push.yml --- .github/workflows/docker-push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index 9c132575f..3b8f0a64d 100644 --- a/.github/workflows/docker-push.yml +++ b/.github/workflows/docker-push.yml @@ -26,9 +26,9 @@ jobs: matrix: include: - platform: linux/amd64 - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 - platform: linux/arm64 - runs-on: ubuntu-24.04-arm + runs-on: ubuntu-26.04-arm runs-on: ${{ matrix.runs-on }} timeout-minutes: 45 steps: From 84df3de5622000c332f94a7f6c89e4cab56c1a03 Mon Sep 17 00:00:00 2001 From: Shlee Date: Fri, 11 Sep 2026 20:20:06 +0930 Subject: [PATCH 6/6] Update StoryExpireRemoteCacheTest.php --- tests/Feature/StoryExpireRemoteCacheTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Feature/StoryExpireRemoteCacheTest.php b/tests/Feature/StoryExpireRemoteCacheTest.php index bdb2e8688..1d475d0df 100644 --- a/tests/Feature/StoryExpireRemoteCacheTest.php +++ b/tests/Feature/StoryExpireRemoteCacheTest.php @@ -3,7 +3,6 @@ use App\Jobs\StoryPipeline\StoryExpire; use App\Models\Profile; use App\Models\Story; -use App\Models\User; use App\Services\StoryIndexService; use App\Services\StoryService; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; @@ -48,7 +47,7 @@ it('invalidates both story caches when a remote story expires', function () { 'expires_at' => now()->addHours(24), ])->save(); $index = app(StoryIndexService::class); - $cacheKey = StoryService::STORY_KEY . 'latest:pid-' . $author->id; + $cacheKey = StoryService::STORY_KEY.'latest:pid-'.$author->id; // Index is empty, so this warms the SQL-backed cache. expect(StoryService::latest($author->id))->toBe($story->id);