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
101 changes: 95 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,26 @@ jobs:
echo "::endgroup::"
done

# Go test suite — sharded across 2 runners (#254).
#
# The workspace has 7 first-party modules and a couple of them (auth,
# migrate) dominate the wall-clock budget. We split the work by
# deterministically partitioning the module list across 2 shards and
# running each on a separate runner in parallel. Sharding by module
# (rather than by test) keeps the per-shard setup amortised — each
# shard still pays for `go work sync` once.
#
# Coverage is collected per shard, uploaded as an artefact, then a
# follow-up `test-go-coverage` job merges the per-shard profiles and
# enforces the 80% project-wide gate.
test-go:
needs: [changes, lint-go]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:16
Expand All @@ -143,10 +159,12 @@ jobs:
cache: true
- name: go work sync
run: go work sync
- name: Run tests (workspace modules)
- name: Run tests (shard ${{ matrix.shard }} of 2)
env:
DATABASE_URL: postgres://postgres:ci@localhost:5432/gonext_test?sslmode=disable
REDIS_URL: redis://localhost:6379
SHARD_INDEX: ${{ matrix.shard }}
SHARD_TOTAL: 2
run: |
# Workspace `use` is the source of truth — `all` would pull
# transitive deps that don't belong to this repo.
Expand All @@ -156,19 +174,90 @@ jobs:
# spin-up jitter, timing-sensitive auth tests, etc). Those
# tests still run in the nightly workflow without -short.
# Individual tests opt in via `if testing.Short() { t.Skip(...) }`.
#
# Module-level sharding: enumerate the workspace modules,
# pick the ones whose 1-based index mod SHARD_TOTAL matches
# this shard. Deterministic, no overlap, and the assignment
# only shifts when a module is added/removed from go.work.
mkdir -p coverage
shard_idx=$SHARD_INDEX
shard_total=$SHARD_TOTAL
i=0
for dir in $(go work edit -json | python3 -c "import json,sys; [print(u['DiskPath']) for u in json.load(sys.stdin)['Use']]"); do
echo "::group::go test $dir"
(cd "$dir" && go test -short -race -count=1 ./...)
i=$((i + 1))
if [ "$(( (i - 1) % shard_total + 1 ))" != "$shard_idx" ]; then
continue
fi
echo "::group::go test [$shard_idx/$shard_total] $dir"
# Write per-module coverage profile; the merge job
# concatenates them later.
safe="$(echo "$dir" | tr '/' '_')"
(cd "$dir" && go test -short -race -count=1 \
-covermode=atomic \
-coverprofile="$GITHUB_WORKSPACE/coverage/${safe}.out" \
./...)
echo "::endgroup::"
done
- name: Upload coverage
- name: Upload coverage shard
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: go-coverage
path: '**/coverage.out'
name: go-coverage-shard-${{ matrix.shard }}
path: coverage/*.out
if-no-files-found: ignore

# Merge coverage shards and enforce the 80% project-wide gate (#254).
#
# Advisory at first — `continue-on-error: true` lets the gate surface
# signal without blocking PRs while the codebase is still catching up
# to the target. Once we're above 80% consistently, a follow-up issue
# flips this to required in the branch protection rule.
test-go-coverage:
needs: [changes, test-go]
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.work'
cache: true
- name: Download all shards
uses: actions/download-artifact@v4
with:
pattern: go-coverage-shard-*
path: coverage-raw
merge-multiple: true
- name: Merge coverage profiles
# The Go cover tool's "mode" line must appear exactly once at
# the top of the merged file. We strip it from every input,
# write a single header, then concatenate the bodies.
run: |
mkdir -p coverage
merged=coverage/merged.out
echo "mode: atomic" > "$merged"
for f in coverage-raw/*.out; do
tail -n +2 "$f" >> "$merged"
done
go tool cover -func="$merged" | tee coverage/summary.txt
- name: Enforce 80% coverage gate
run: |
# `go tool cover -func` ends with a line like:
# total: (statements) 76.2%
total="$(awk '/^total:/ {print $NF}' coverage/summary.txt | tr -d '%')"
echo "Project coverage: ${total}%"
echo "## Coverage" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Total**: ${total}%" >> "$GITHUB_STEP_SUMMARY"
echo "**Threshold**: 80%" >> "$GITHUB_STEP_SUMMARY"
# Threshold check in awk so we don't depend on bash floats.
if awk -v t="$total" 'BEGIN { exit !(t+0 < 80.0) }'; then
echo "::warning::project coverage ${total}% is below the 80% gate"
exit 1
fi
echo "Coverage gate OK"

lint-web:
needs: changes
if: needs.changes.outputs.web == 'true'
Expand Down
147 changes: 147 additions & 0 deletions .github/workflows/theme-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Theme publishing pipeline (#139).
#
# Mirrors the plugin signing flow from #130 (.github/workflows/plugin-publish.yml).
# Themes are simpler than plugins — there's no WASM bundle and no
# capability surface to diff — but the chain of trust is identical:
# the artefact is built from a tagged commit, signed via cosign keyless
# (Fulcio OIDC) so the signature binds the workflow's identity, and
# attached to the GitHub release.
#
# Trigger: a tag push of the form `theme/<slug>/vX.Y.Z`. The workflow:
# 1. Builds the theme bundle (a zip of the theme directory).
# 2. Signs the bundle with cosign keyless using the workflow's OIDC.
# 3. Uploads the bundle + signature artefacts to the GitHub release
# created for the tag.
#
# # Artefact layout
#
# Three files attached to the release:
# - <slug>-<version>.gntheme : the zipped theme directory
# - <slug>-<version>.gntheme.sig : cosign signature (base64)
# - <slug>-<version>.gntheme.pem : the Fulcio cert chain
#
# Operators verify with:
# cosign verify-blob \
# --certificate <slug>-<version>.gntheme.pem \
# --signature <slug>-<version>.gntheme.sig \
# --certificate-identity-regexp 'github.com/Singleton-Solution/GoNext/.github/workflows/theme-publish.yml@.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# <slug>-<version>.gntheme

name: theme-publish

on:
push:
tags:
- 'theme/*/v*'

# Required for cosign keyless (Fulcio OIDC token) and for writing the
# GitHub release.
permissions:
contents: write
id-token: write

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # so the release notes can reference prior tags

# The tag is theme/<slug>/vX.Y.Z; pluck the slug + version.
- name: Parse tag
id: tag
run: |
tag="${GITHUB_REF#refs/tags/}"
slug="$(echo "$tag" | cut -d'/' -f2)"
version="$(echo "$tag" | cut -d'/' -f3)"
version="${version#v}"
echo "slug=$slug" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"

- name: Verify theme directory exists
run: |
theme_dir="themes/${{ steps.tag.outputs.slug }}"
if [ ! -d "$theme_dir" ]; then
echo "::error::theme directory '$theme_dir' does not exist"
exit 1
fi
# Required files: theme.json (manifest) and at least one
# template. The CLI installer rejects bundles without these,
# so failing here gives the publisher a faster signal.
if [ ! -f "$theme_dir/theme.json" ]; then
echo "::error::missing theme.json in '$theme_dir'"
exit 1
fi

# Build the bundle: a zip of the theme directory, named so the
# operator can drop it straight into the admin upload form.
- name: Build theme bundle
id: build
run: |
mkdir -p dist
bundle="dist/${{ steps.tag.outputs.slug }}-${{ steps.tag.outputs.version }}.gntheme"
(cd themes && zip -r "../$bundle" "${{ steps.tag.outputs.slug }}")
echo "bundle=$bundle" >> "$GITHUB_OUTPUT"
# Record sha256 for the release notes — operators with paranoid
# download workflows can verify the artefact bit-for-bit.
sha="$(sha256sum "$bundle" | awk '{print $1}')"
echo "sha256=$sha" >> "$GITHUB_OUTPUT"

# Cosign keyless sign. The OIDC token comes from id-token:write
# above; cosign binds the Fulcio cert to the workflow's identity
# (e.g. github.com/Singleton-Solution/GoNext/.github/workflows/theme-publish.yml).
- name: Install cosign
uses: sigstore/cosign-installer@v3.6.0

- name: Sign bundle
env:
COSIGN_EXPERIMENTAL: "1"
COSIGN_YES: "true"
run: |
bundle="${{ steps.build.outputs.bundle }}"
cosign sign-blob \
--yes \
--output-signature "${bundle}.sig" \
--output-certificate "${bundle}.pem" \
"$bundle"

# Attach to the release. The release is created (or updated, if
# someone re-pushed the tag) with the bundle + signature artefacts.
# softprops/action-gh-release handles the "release already exists"
# case idempotently.
- name: Create/Update release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.slug }} v${{ steps.tag.outputs.version }}
body: |
## ${{ steps.tag.outputs.slug }} v${{ steps.tag.outputs.version }}

Signed theme bundle. Verify with cosign before installing:

```bash
cosign verify-blob \
--certificate ${{ steps.tag.outputs.slug }}-${{ steps.tag.outputs.version }}.gntheme.pem \
--signature ${{ steps.tag.outputs.slug }}-${{ steps.tag.outputs.version }}.gntheme.sig \
--certificate-identity-regexp 'github.com/${{ github.repository }}/.github/workflows/theme-publish.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
${{ steps.tag.outputs.slug }}-${{ steps.tag.outputs.version }}.gntheme
```

**SHA-256**: `${{ steps.build.outputs.sha256 }}`
files: |
${{ steps.build.outputs.bundle }}
${{ steps.build.outputs.bundle }}.sig
${{ steps.build.outputs.bundle }}.pem

- name: Summary
run: |
echo "## Published" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **slug**: ${{ steps.tag.outputs.slug }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **version**: ${{ steps.tag.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **sha256**: \`${{ steps.build.outputs.sha256 }}\`" >> "$GITHUB_STEP_SUMMARY"
104 changes: 104 additions & 0 deletions apps/web/src/components/responsive-image/ResponsiveImage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Unit tests for ResponsiveImage. Run under vitest + happy-dom.
*
* The contract we care about: given the inputs an admin/migrator would
* pass, the emitted DOM is a `<picture>` with the right source order,
* fallback `<img>`, and the lazy/async loading attributes.
*/
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ResponsiveImage, buildWidthSrcSet } from './ResponsiveImage';

describe('buildWidthSrcSet', () => {
it('returns an empty string for no widths', () => {
expect(buildWidthSrcSet('/img.jpg', [])).toBe('');
});

it('appends ?w=N for plain URLs', () => {
expect(buildWidthSrcSet('/img.jpg', [256, 1024])).toBe(
'/img.jpg?w=256 256w, /img.jpg?w=1024 1024w',
);
});

it('uses & when the URL already has a query string', () => {
expect(buildWidthSrcSet('/img.jpg?v=1', [256])).toBe('/img.jpg?v=1&w=256 256w');
});

it('sorts widths narrowest-first', () => {
expect(buildWidthSrcSet('/x.jpg', [1024, 256, 768])).toBe(
'/x.jpg?w=256 256w, /x.jpg?w=768 768w, /x.jpg?w=1024 1024w',
);
});
});

describe('<ResponsiveImage>', () => {
it('renders a fallback <img> with the canonical src', () => {
render(<ResponsiveImage src="/img.jpg" alt="hero" />);
const img = screen.getByAltText('hero') as HTMLImageElement;
expect(img.tagName).toBe('IMG');
expect(img.getAttribute('src')).toBe('/img.jpg');
});

it('defaults to lazy + async', () => {
render(<ResponsiveImage src="/img.jpg" alt="hero" />);
const img = screen.getByAltText('hero');
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
});

it('priority flips loading/decoding to eager/sync', () => {
render(<ResponsiveImage src="/img.jpg" alt="hero" priority />);
const img = screen.getByAltText('hero');
expect(img.getAttribute('loading')).toBe('eager');
expect(img.getAttribute('decoding')).toBe('sync');
});

it('renders a <source> per format when sources are provided', () => {
const { container } = render(
<ResponsiveImage
src="/img.jpg"
alt="hero"
sources={[
{ srcset: '/img.avif?w=480 480w, /img.avif?w=1024 1024w', type: 'image/avif' },
{ srcset: '/img.webp?w=480 480w', type: 'image/webp' },
]}
/>,
);
const sources = container.querySelectorAll('source');
expect(sources).toHaveLength(2);
expect(sources[0]?.getAttribute('type')).toBe('image/avif');
expect(sources[1]?.getAttribute('type')).toBe('image/webp');
});

it('synthesises a width-based srcset when only widths are provided', () => {
const { container } = render(
<ResponsiveImage src="/img.jpg" alt="hero" widths={[256, 1024]} />,
);
const source = container.querySelector('source');
expect(source).not.toBeNull();
expect(source?.getAttribute('srcset')).toBe('/img.jpg?w=256 256w, /img.jpg?w=1024 1024w');
});

it('passes through width/height for aspect-ratio preservation', () => {
render(<ResponsiveImage src="/img.jpg" alt="hero" width={1200} height={630} />);
const img = screen.getByAltText('hero');
expect(img.getAttribute('width')).toBe('1200');
expect(img.getAttribute('height')).toBe('630');
});

it('uses the default sizes attribute on synthesised sources', () => {
const { container } = render(
<ResponsiveImage src="/img.jpg" alt="hero" widths={[256]} />,
);
const source = container.querySelector('source');
expect(source?.getAttribute('sizes')).toContain('max-width: 480px');
});

it('honours an explicit sizes attribute', () => {
const { container } = render(
<ResponsiveImage src="/img.jpg" alt="hero" widths={[256]} sizes="100vw" />,
);
const source = container.querySelector('source');
expect(source?.getAttribute('sizes')).toBe('100vw');
});
});
Loading
Loading