diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feaf0fca..1c481c18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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. @@ -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' diff --git a/.github/workflows/theme-publish.yml b/.github/workflows/theme-publish.yml new file mode 100644 index 00000000..4b830056 --- /dev/null +++ b/.github/workflows/theme-publish.yml @@ -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//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: +# - -.gntheme : the zipped theme directory +# - -.gntheme.sig : cosign signature (base64) +# - -.gntheme.pem : the Fulcio cert chain +# +# Operators verify with: +# cosign verify-blob \ +# --certificate -.gntheme.pem \ +# --signature -.gntheme.sig \ +# --certificate-identity-regexp 'github.com/Singleton-Solution/GoNext/.github/workflows/theme-publish.yml@.*' \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com \ +# -.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//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" diff --git a/apps/web/src/components/responsive-image/ResponsiveImage.test.tsx b/apps/web/src/components/responsive-image/ResponsiveImage.test.tsx new file mode 100644 index 00000000..4802d32d --- /dev/null +++ b/apps/web/src/components/responsive-image/ResponsiveImage.test.tsx @@ -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 `` with the right source order, + * fallback ``, 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('', () => { + it('renders a fallback with the canonical src', () => { + render(); + 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(); + 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(); + const img = screen.getByAltText('hero'); + expect(img.getAttribute('loading')).toBe('eager'); + expect(img.getAttribute('decoding')).toBe('sync'); + }); + + it('renders a per format when sources are provided', () => { + const { container } = render( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + const source = container.querySelector('source'); + expect(source?.getAttribute('sizes')).toContain('max-width: 480px'); + }); + + it('honours an explicit sizes attribute', () => { + const { container } = render( + , + ); + const source = container.querySelector('source'); + expect(source?.getAttribute('sizes')).toBe('100vw'); + }); +}); diff --git a/apps/web/src/components/responsive-image/ResponsiveImage.tsx b/apps/web/src/components/responsive-image/ResponsiveImage.tsx new file mode 100644 index 00000000..52724748 --- /dev/null +++ b/apps/web/src/components/responsive-image/ResponsiveImage.tsx @@ -0,0 +1,157 @@ +/** + * ResponsiveImage — render a responsive with srcset variants. + * + * # Pairing with the server-side image pipeline + * + * The server-side image processor in + * `packages/go/media/imageproc/srcset.go` produces a per-format list of + * `(url, width)` pairs derived from the original asset (AVIF / WebP / + * JPEG/PNG fallback). The REST surface emits those as a `variants` + * payload alongside the canonical `src`. This component is the + * client-side mirror: it accepts the same shape and renders a `` + * with a `` per format and a fallback `` so capable + * browsers pick AVIF/WebP and older clients fall through. + * + * # Why , not just + * + * A single `` with `srcset` can only switch between widths of the + * SAME format. The server emits multiple formats (AVIF first, then + * WebP, then a JPEG/PNG fallback), and we want the browser to pick the + * best decode-supported format. That's what `` + `` is for. + * + * # The author can pass either shape + * + * Two ergonomic call sites: + * 1. Author has only a URL and a list of widths + * (the simple case the migrator emits): + * + * 2. Author has the full variants payload from the API: + * + * + * In case 1 we synthesise a single `` worth of srcset by + * appending `?w=` query params — the API recognises that shape and + * negotiates the right variant on first hit, then caches it. + * + * # Lazy by default + * + * Every instance gets `loading="lazy"` and `decoding="async"` unless + * the caller explicitly sets `priority` (e.g. a hero image above the + * fold should be eager). This matches Lighthouse's recommendation for + * the common case (gallery, post body, listing thumbnails). + */ +import type { CSSProperties, ReactElement } from 'react'; + +/** + * One `` entry. `srcset` is already-formatted ("url 480w, url 1024w"), + * `type` is the MIME type ("image/avif", "image/webp", ...). + * + * Matches the wire shape produced by + * `packages/go/media/imageproc/srcset.go#PictureSource`. + */ +export interface PictureSource { + srcset: string; + type: string; +} + +export interface ResponsiveImageProps { + /** Canonical (fallback) URL — what unaware browsers load. */ + src: string; + /** Alt text. Required; pass "" only for decorative images. */ + alt: string; + /** + * Pre-built `` entries — when the caller has the full + * variants payload from the API. AVIF first, then WebP, then any + * JPEG/PNG fallbacks. + */ + sources?: PictureSource[]; + /** + * Convenience: when the caller only knows widths (e.g. theme author + * passing `[256, 768, 1536]`), we synthesise a single srcset by + * appending `?w=` query params to `src`. Ignored if `sources` is set. + */ + widths?: number[]; + /** + * The `sizes` attribute. The server provides a sane default + * (`(max-width: 480px) 256px, (max-width: 1024px) 768px, 1536px`); + * themes may pass tighter ones based on grid breakpoints. + */ + sizes?: string; + /** Intrinsic width — drives the rendered aspect ratio. */ + width?: number; + /** Intrinsic height — drives the rendered aspect ratio. */ + height?: number; + /** Treat this image as above-the-fold (no lazy, decode sync). */ + priority?: boolean; + /** className/style passthrough for the ``. */ + className?: string; + style?: CSSProperties; +} + +const DEFAULT_SIZES = '(max-width: 480px) 256px, (max-width: 1024px) 768px, 1536px'; + +/** + * Build a srcset string from a base URL + a list of widths. The + * resulting URLs use the `?w=N` shape the media API recognises. + * + * Exposed for tests; not part of the package's external API. + */ +export function buildWidthSrcSet(src: string, widths: number[]): string { + if (widths.length === 0) { + return ''; + } + const separator = src.includes('?') ? '&' : '?'; + // Sort narrowest-first for determinism — matches the server-side + // ordering in imageproc.BuildSrcSet. + const sorted = [...widths].sort((a, b) => a - b); + return sorted.map((w) => `${src}${separator}w=${w} ${w}w`).join(', '); +} + +export function ResponsiveImage({ + src, + alt, + sources, + widths, + sizes = DEFAULT_SIZES, + width, + height, + priority, + className, + style, +}: ResponsiveImageProps): ReactElement { + // Loading: eager for above-the-fold, lazy otherwise. We don't expose + // "eager" directly because the only legitimate use is the priority + // path — eager loading hurts LCP on the common case. + const loading = priority ? 'eager' : 'lazy'; + // decoding=async lets the browser decode off the main thread; "sync" + // is only useful for priority paints where the painter is blocked + // waiting for the decode anyway. + const decoding = priority ? 'sync' : 'async'; + + // The synthesised srcset path. Only used when the caller didn't pass + // pre-built `sources`. + const widthSrcSet = + !sources && widths && widths.length > 0 ? buildWidthSrcSet(src, widths) : undefined; + + return ( + + {sources?.map((s) => ( + // The `` order matters: capable browsers pick the FIRST + // matching one. The server emits AVIF before WebP before + // JPEG/PNG fallbacks, which is what we want. + + ))} + {widthSrcSet && } + {alt} + + ); +} diff --git a/apps/web/src/components/responsive-image/index.ts b/apps/web/src/components/responsive-image/index.ts new file mode 100644 index 00000000..a4bae562 --- /dev/null +++ b/apps/web/src/components/responsive-image/index.ts @@ -0,0 +1,5 @@ +// Re-export so callers do +// import { ResponsiveImage } from '@/components/responsive-image'; +// rather than reaching into the file path. +export { ResponsiveImage, buildWidthSrcSet } from './ResponsiveImage'; +export type { ResponsiveImageProps, PictureSource } from './ResponsiveImage'; diff --git a/cli/gonext/cmd/migrate/migrate.go b/cli/gonext/cmd/migrate/migrate.go index 6268b2af..1ae7624c 100644 --- a/cli/gonext/cmd/migrate/migrate.go +++ b/cli/gonext/cmd/migrate/migrate.go @@ -37,6 +37,8 @@ Subcommands: theme (gn-hello) on first boot. Default: true. down [N] Roll back N migrations (default 1). Pass 0 to roll back ALL. + to Migrate up or down to reach the given version (positive + integer matching the migration filename prefix). status Print the current schema version and dirty flag. wp Import a WordPress WXR export. See 'migrate wp --help'. verify Verify a WordPress import for fidelity. See 'migrate verify --help'. @@ -71,6 +73,8 @@ func Run(args []string, stdout, stderr io.Writer) int { return runUp(args[1:], stdout, stderr) case "down": return runDown(args[1:], stdout, stderr) + case "to": + return runTo(args[1:], stdout, stderr) case "status": return runStatus(args[1:], stdout, stderr) case "wp": @@ -192,6 +196,35 @@ func runDown(args []string, stdout, stderr io.Writer) int { return ExitOK } +// runTo migrates the schema to a specific target version. We accept +// exactly one positional arg (the version) and parse it as a non-zero +// positive integer — passing 0 is rejected here (and in pkgmigrate.To) +// because rolling back ALL migrations should go through `migrate down 0` +// where the destructive intent is more obvious. +func runTo(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintf(stderr, "gonext migrate to: expected exactly one argument \n\n%s\n", usage) + return ExitUsage + } + v, err := strconv.ParseUint(args[0], 10, 32) + if err != nil || v == 0 { + fmt.Fprintf(stderr, "gonext migrate to: invalid version %q (want a positive integer)\n", args[0]) + return ExitUsage + } + cfg, logger, code := loadConfig(stderr) + if code != ExitOK { + return code + } + ctx, cancel := contextWithCancel() + defer cancel() + if err := pkgmigrate.To(ctx, cfg, logger, uint(v)); err != nil { + fmt.Fprintf(stderr, "gonext migrate to: %v\n", err) + return ExitFail + } + fmt.Fprintf(stdout, "migrate: to %d OK\n", v) + return ExitOK +} + // runStatus prints the current version + dirty flag. func runStatus(args []string, stdout, stderr io.Writer) int { if len(args) > 0 { diff --git a/cli/gonext/cmd/migrate/migrate_test.go b/cli/gonext/cmd/migrate/migrate_test.go index 82fb8d42..00d2a482 100644 --- a/cli/gonext/cmd/migrate/migrate_test.go +++ b/cli/gonext/cmd/migrate/migrate_test.go @@ -76,6 +76,37 @@ func TestRun_Down_RejectsNonNumericSteps(t *testing.T) { } } +func TestRun_To_RequiresExactlyOneArg(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x@x/x") + var stdout, stderr bytes.Buffer + if code := Run([]string{"to"}, &stdout, &stderr); code != ExitUsage { + t.Errorf("no arg: got %d, want %d", code, ExitUsage) + } + stdout.Reset() + stderr.Reset() + if code := Run([]string{"to", "10", "20"}, &stdout, &stderr); code != ExitUsage { + t.Errorf("two args: got %d, want %d", code, ExitUsage) + } +} + +func TestRun_To_RejectsZero(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x@x/x") + var stdout, stderr bytes.Buffer + code := Run([]string{"to", "0"}, &stdout, &stderr) + if code != ExitUsage { + t.Errorf("exit: got %d, want %d", code, ExitUsage) + } +} + +func TestRun_To_RejectsNonNumeric(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x@x/x") + var stdout, stderr bytes.Buffer + code := Run([]string{"to", "abc"}, &stdout, &stderr) + if code != ExitUsage { + t.Errorf("exit: got %d, want %d", code, ExitUsage) + } +} + func TestRun_Up_RejectsTrailingArgs(t *testing.T) { t.Setenv("DATABASE_URL", "postgres://x@x/x") var stdout, stderr bytes.Buffer diff --git a/packages/go/migrate/migrate.go b/packages/go/migrate/migrate.go index eaf6140b..f9ce55ad 100644 --- a/packages/go/migrate/migrate.go +++ b/packages/go/migrate/migrate.go @@ -118,6 +118,52 @@ func Down(ctx context.Context, cfg config.DatabaseConfig, logger *slog.Logger, s }) } +// To migrates the schema to a specific target version, going up or +// down as necessary. If the current version is below target, pending +// up migrations are applied until version == target; if above, down +// migrations are rolled back until version == target. If the current +// version already equals target the call is a no-op. +// +// target must be a positive migration version (matching the +// `NNNNNN_*.{up,down}.sql` filename prefix). Passing 0 returns an +// error — to roll back ALL migrations, use Down(ctx, cfg, logger, 0) +// explicitly so the destructive intent is visible at the call site. +// +// Like Run/Down, To acquires a Postgres advisory lock for the duration. +func To(ctx context.Context, cfg config.DatabaseConfig, logger *slog.Logger, target uint) error { + logger = ensureLogger(logger) + if target == 0 { + return fmt.Errorf("migrate.To: target must be > 0 (use Down(..., 0) to roll back all)") + } + return withLock(ctx, cfg, logger, "to", func(m *migrate.Migrate) error { + start := time.Now() + err := m.Migrate(target) + switch { + case err == nil: + ver, dirty := currentVersion(m) + logger.Info("migrated to target version", + slog.String("op", "to"), + slog.Uint64("target", uint64(target)), + slog.Uint64("version", uint64(ver)), + slog.Bool("dirty", dirty), + slog.Duration("took", time.Since(start)), + ) + return nil + case errors.Is(err, migrate.ErrNoChange): + ver, dirty := currentVersion(m) + logger.Info("already at target version", + slog.String("op", "to"), + slog.Uint64("target", uint64(target)), + slog.Uint64("version", uint64(ver)), + slog.Bool("dirty", dirty), + ) + return nil + default: + return fmt.Errorf("migrate.To: %w", err) + } + }) +} + // Status reports the current schema_migrations state. // // current is the highest applied migration version (0 if none). diff --git a/packages/go/migrate/migrate_test.go b/packages/go/migrate/migrate_test.go index e498c1b8..87e3b812 100644 --- a/packages/go/migrate/migrate_test.go +++ b/packages/go/migrate/migrate_test.go @@ -83,6 +83,21 @@ func TestDown_NegativeStepsRejected(t *testing.T) { } } +func TestTo_ZeroTargetRejected(t *testing.T) { + // target == 0 is reserved for "all" in Down(); To() must refuse it + // so the destructive case is explicit at the call site. + err := To(context.Background(), config.DatabaseConfig{ + URL: "postgres://x@x/x", + MigrationDir: "./migrations", + }, quietLogger(), 0) + if err == nil { + t.Fatal("expected error for target=0") + } + if !strings.Contains(err.Error(), "target") { + t.Errorf("error should mention target, got: %v", err) + } +} + func TestSourceURLForDir(t *testing.T) { tmp := t.TempDir() got, err := sourceURLForDir(tmp)