diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index 5d0264b99..3b8f0a64d 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-26.04 + - platform: linux/arm64 + runs-on: ubuntu-26.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 }} diff --git a/.github/workflows/docker-tag.yml b/.github/workflows/docker-tag.yml index 734619731..20ceba278 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-26.04 + - platform: linux/arm64 + runs-on: ubuntu-26.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" diff --git a/tests/Feature/StoryExpireRemoteCacheTest.php b/tests/Feature/StoryExpireRemoteCacheTest.php index 84e5259e1..1d475d0df 100644 --- a/tests/Feature/StoryExpireRemoteCacheTest.php +++ b/tests/Feature/StoryExpireRemoteCacheTest.php @@ -3,10 +3,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 +39,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); + $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(); });