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
65 changes: 49 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,11 @@ jobs:
- 'docker/tb-cli/**'

- name: Define Node test matrix
# Node lines the unit / legacy / acceptance suites must keep passing on.
# test:unit's Nx cache is keyed on `node -v` (nx.json) so each leg runs.
id: node_matrix
run: |
echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT
echo 'matrix=["22.23.1", "24.20.0"]' >> $GITHUB_OUTPUT

- name: Start Nx Cloud CI run
run: pnpm nx start-ci-run
Expand Down Expand Up @@ -552,8 +554,9 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
trust-lockfile: 'true'
# --force for the same reason as job_unit-tests: better-sqlite3 is an
# optionalDependency and boot uses it for the development database.
# better-sqlite3 is an optionalDependency that pnpm may skip when
# restoring from a cached store, and boot needs it for the
# development database.
install-args: --force

- name: Install hyperfine
Expand Down Expand Up @@ -679,6 +682,7 @@ jobs:
needs: [job_setup]
if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.unit_test_projects_str != ''
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }}
name: Unit tests (Node ${{ matrix.node }})
Expand All @@ -690,11 +694,9 @@ jobs:
with:
node-version: ${{ matrix.node }}
trust-lockfile: 'true'
# better-sqlite3 is an optionalDependency. Without --force, pnpm may skip
# installing/linking it when restoring from a cached store. --force
# ensures all optional deps are installed regardless.
# (ghost core's test:unit job requires better-sqlite3)
install-args: --force
# Without --no-runtime, pnpm links the devEngines Node and every
# leg of this matrix runs that same version.
install-args: --no-runtime

- name: Set timezone (non-UTC)
uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0
Expand Down Expand Up @@ -778,15 +780,24 @@ jobs:
--health-interval=2s
--health-timeout=5s
--health-retries=60
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }}
env:
COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled }}
name: Acceptance tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8)
# Only the primary Node leg is instrumented — the others cover the same
# code, and a second leg would collide on the e2e-coverage artifact name.
COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.node == needs.job_setup.outputs.node_version }}
name: Acceptance tests (Node ${{ matrix.node }}, mysql8)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ needs.job_setup.outputs.node_version }}
node-version: ${{ matrix.node }}
trust-lockfile: 'true'
# Without --no-runtime, pnpm links the devEngines Node and every
# leg of this matrix runs that same version.
install-args: --no-runtime

- name: Set timezone (non-UTC)
uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0
Expand Down Expand Up @@ -878,15 +889,22 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=12
name: Legacy tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8)
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }}
name: Legacy tests (Node ${{ matrix.node }}, mysql8)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
submodules: true
- uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ needs.job_setup.outputs.node_version }}
node-version: ${{ matrix.node }}
trust-lockfile: 'true'
# Without --no-runtime, pnpm links the devEngines Node and every
# leg of this matrix runs that same version.
install-args: --no-runtime

- name: Set env vars (MySQL)
run: |
Expand Down Expand Up @@ -1048,16 +1066,29 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

job_ghost-cli:
name: Ghost-CLI tests
name: Ghost-CLI tests (${{ matrix.scenario }})
needs: [job_setup, job_pack]
if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# This build, installed from the tarball this run produced.
- scenario: clean-install
node: ${{ needs.job_setup.outputs.node_version }}
# Upgrade from the newest Ghost on npm, which is pinned to the Node
# version whose `engines` that release declared — it can't be raised
# until a release ships supporting the newer line. Move this to
# node_version once the published release supports it.
- scenario: latest-release
node: '22.23.1'
steps:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
env:
FORCE_COLOR: 0
with:
node-version: ${{ env.NODE_VERSION }}
node-version: ${{ matrix.node }}

- name: Install Ghost-CLI
run: npm install -g ghost-cli@latest
Expand All @@ -1079,10 +1110,11 @@ jobs:
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ghost-cli-debug-logs
name: ghost-cli-debug-logs-${{ matrix.scenario }}
path: /home/runner/.ghost/logs/

- name: Clean Install
if: matrix.scenario == 'clean-install'
run: |
DIR=$(mktemp -d)
ghost install local -d "$DIR" --archive "$(pwd)/ghost.tgz"
Expand All @@ -1091,6 +1123,7 @@ jobs:
ghost stop -d "$DIR"

- name: Latest Release
if: matrix.scenario == 'latest-release'
# --force skips Ghost-CLI's version comparison between the archive and
# the installed release. Without it this step is coupled to how far the
# branch has drifted from main: a PR branched before the last release
Expand Down
237 changes: 237 additions & 0 deletions apps/admin-x-framework/src/api/content-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/** Shared Admin API type contracts for posts and pages. */

type Override<Base, Changes> = Omit<Base, keyof Changes> & Changes;

export type Email = {
opened_count: number;
email_count: number;
status?: string;
track_opens?: boolean;
track_clicks?: boolean;
};

// Every field is optional because list and analytics endpoints return different
// projections of these relations.
export type PostAuthor = {
id?: string;
name?: string;
email?: string;
slug?: string;
profile_image?: string | null;
};

export type PostTag = {
id?: string;
name?: string;
slug?: string;
visibility?: string;
};

export type PostStatus = 'published' | 'draft' | 'scheduled' | 'sent';
export type PageStatus = Exclude<PostStatus, 'sent'>;

type AtLeastOne<T> = {
[Key in keyof T]-?: Required<Pick<T, Key>> & Partial<Omit<T, Key>>;
}[keyof T];

export type PostAuthorInput = string | AtLeastOne<{ id: string; slug: string; email: string }>;

export type PostTagInput =
| string
| ({ id: string } & Partial<{ name: string; slug: string | null }>)
| ({ name: string } & Partial<{ id: string; slug: string | null }>)
| ({ slug: string } & Partial<{ id: string; name: string }>);

export type PostTierInput = { id: string };

export type PostTier = {
id: string;
name?: string;
slug?: string | null;
};

export type PostRevision = {
id?: string;
post_id?: string;
lexical?: string | null;
title?: string | null;
feature_image?: string | null;
feature_image_alt?: string | null;
feature_image_caption?: string | null;
custom_excerpt?: string | null;
post_status?: string | null;
reason?: string | null;
created_at?: string;
author?: PostAuthor | null;
};

/** Fields shared by post and page list responses. */
export type ContentListFields = {
featured?: boolean;
updated_at?: string | null;
created_at?: string;
excerpt?: string | null;
custom_excerpt?: string | null;
authors?: PostAuthor[];
primary_author?: PostAuthor | null;
tags?: PostTag[];
primary_tag?: PostTag | null;
tiers?: object[];
};

/** Fields shared by post and page editor responses. */
export type ContentEditorFields = {
lexical?: string | null;
mobiledoc?: string | null;
meta_title?: string | null;
meta_description?: string | null;
canonical_url?: string | null;
custom_template?: string | null;
codeinjection_head?: string | null;
codeinjection_foot?: string | null;
og_image?: string | null;
og_title?: string | null;
og_description?: string | null;
twitter_image?: string | null;
twitter_title?: string | null;
twitter_description?: string | null;
feature_image_alt?: string | null;
feature_image_caption?: string | null;
post_revisions?: PostRevision[];
};

export type PostCount = {
clicks?: number;
conversions?: number;
signups?: number;
paid_conversions?: number;
positive_feedback?: number;
negative_feedback?: number;
};

export type PageCount = {
signups?: number;
paid_conversions?: number;
};

/** Broad response record shared by the posts and pages endpoints. */
export type ContentRecord = {
id: string;
url: string;
slug: string;
title: string;
visibility?: string;
uuid?: string;
feature_image?: string | null;
published_at?: string | null;
} & ContentListFields &
ContentEditorFields;

export type PostEmailFields = {
email?: Email | null;
email_subject?: string | null;
newsletter?: object | null;
email_only?: boolean;
email_segment?: string | null;
};

export type Post = Override<
ContentRecord,
{
uuid: string;
status?: PostStatus;
count?: PostCount;
}
> &
PostEmailFields;

export type Page = Override<
ContentRecord,
{
status?: PageStatus;
count?: PageCount;
show_title_and_feature_image?: boolean;
}
>;

// Write access stays opt-in: a new response field must not silently become
// writable. Pick supplies each allowed field's value type from ContentRecord.
type ContentWritableKey =
| 'title'
| 'slug'
| 'mobiledoc'
| 'lexical'
| 'feature_image'
| 'feature_image_alt'
| 'feature_image_caption'
| 'featured'
| 'meta_title'
| 'meta_description'
| 'updated_at'
| 'published_at'
| 'custom_excerpt'
| 'codeinjection_head'
| 'codeinjection_foot'
| 'og_image'
| 'og_title'
| 'og_description'
| 'twitter_image'
| 'twitter_title'
| 'twitter_description'
| 'custom_template'
| 'canonical_url';

type ContentEditableScalars = Partial<Pick<ContentRecord, ContentWritableKey>>;

/** Shared post/page input, including fields whose input shape differs from output. */
export type ContentEditableData = Override<
ContentEditableScalars,
{
html?: string | null;
locale?: string | null;
// The serializer treats null visibility as "leave visibility unchanged".
visibility?: string | null;
visibility_filter?: string | null;
authors?: PostAuthorInput[];
tags?: PostTagInput[];
tiers?: PostTierInput[];
}
>;

export type PostEditableData = ContentEditableData &
Partial<Pick<Post, 'status' | 'email_subject' | 'email_only'>>;

export type PageEditableData = ContentEditableData &
Partial<Pick<Page, 'status' | 'show_title_and_feature_image'>>;

type EditorRelations = {
updated_at: string | null;
authors?: Array<PostAuthor & { id: string }>;
tags?: Array<PostTag & { id: string }>;
tiers?: PostTier[];
};

/** A single editor response has the relations required for a safe round-trip edit. */
export type EditorRecord<RecordType extends ContentRecord> = Override<RecordType, EditorRelations>;

export type PostEditorRecord = EditorRecord<Post>;
export type PageEditorRecord = EditorRecord<Page>;

export type CreateContentData<Data extends { title?: string }> = Override<Data, { title: string }>;

export type EditContentData<Data extends { updated_at?: string | null }> = Override<
Data,
{ id: string; updated_at: string | null }
>;

export type PostBulkAction =
| { type: 'feature' }
| { type: 'unfeature' }
| { type: 'unpublish' }
| { type: 'unschedule' }
| { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } }
| { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } };

// Compatibility aliases for existing imports from api/posts.
export type PostListFields = ContentListFields;
export type PostEditorFields = ContentEditorFields & Pick<PostEmailFields, 'email_subject'>;
Loading
Loading