Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c82593f
Renamed the gifts legacy jobs binding to jobManager
allouis Aug 31, 2026
41df1ba
Renamed the gifts cleanup scheduling parameter to jobsService
allouis Aug 31, 2026
d3eba76
Changed member cleanup scheduling to take the jobs service as a param…
allouis Aug 31, 2026
4734a60
Changed the clean-gifts handler to use an injected gift service
allouis Aug 31, 2026
aab0331
Moved member cleanup task wiring into the members jobs module
allouis Aug 31, 2026
4bd6190
Extracted webmention processing out of the receive job closure
allouis Aug 27, 2026
558a49e
Changed the webmention receiving tests to wait on processing, not the…
allouis Aug 27, 2026
6dab2e5
Moved jobs service construction ahead of service initialisation
allouis Aug 27, 2026
d1b7f2a
Moved webmention receiving to the class-based jobs service
allouis Aug 27, 2026
15cd05b
✨ Released the React tag details screen (#30377)
9larsons Aug 31, 2026
c69dd87
Fixed automation list timestamp formatting (#30380)
troyciesco Aug 31, 2026
30273c6
Fixed Miscellaneous CI issues (#30382)
acburdine Aug 31, 2026
ba4c309
Reordered CSV import error annotations
PaulAdamDavis Aug 31, 2026
17aa890
Added CSV error attachment guidance to completion emails
PaulAdamDavis Aug 31, 2026
a1c2ed7
Removed frontmatter from CSV content imports
PaulAdamDavis Aug 31, 2026
c55b904
🐛 Fixed duplicate requests for image dimensions of the same URL (#30383)
muratcorlu Aug 31, 2026
38adb9e
Removed SQLite Core test plumbing (#30352)
9larsons Aug 31, 2026
82124f2
Changed the acceptance harness labs option to compose with boot overr…
9larsons Aug 31, 2026
b972c1a
Cleaned duplicated labs flags out of admin acceptance specs (#30387)
9larsons Aug 31, 2026
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
16 changes: 15 additions & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ How we write GitHub Actions workflows safely. Follow these when adding or editin
## Secrets

- Expose a secret only to the job that uses it. Do not make secrets available to jobs that run untrusted code.
- **Never pass a secret to a local action (`uses: ./...`) on a `pull_request` run.** A `pull_request` run executes the merge commit (`GITHUB_REF` is `refs/pull/N/merge`), so the workflow file and every local action it calls are PR-authored — an input you pass is an input the PR reads. A guard _inside_ the action is too late; the secret is already an input. Gate it at the caller:

```yaml
# Bad — the PR controls .github/actions/foo/action.yml
github-token: ${{ secrets.SOME_PAT }}

# Good — push runs publish, PR runs get an empty string
github-token: ${{ github.event_name != 'pull_request' && secrets.SOME_PAT || '' }}
```

Fork PRs get no secrets, so this bites on same-repo branches — and there it is damage control rather than a boundary, since anyone who can push a branch can also edit the workflow. What it does buy is that a compromised dependency or third-party action in that job never sees the secret.

- **A secret is exposed to its whole job, not just its step.** Steps share a runner, so PR-authored code running earlier in the job — a repo script, a build, a lifecycle hook — can shadow a binary on `$GITHUB_PATH` or write `$GITHUB_ENV` and capture the secret from a later step. If a job checks out PR code, keep secrets out of it entirely and do the privileged work in a separate job with no checkout.
- Prefer OIDC (`id-token: write`) over long-lived stored secrets where the provider supports it.

## Supply chain
Expand All @@ -54,4 +67,5 @@ How we write GitHub Actions workflows safely. Follow these when adding or editin
3. Trigger is `pull_request` unless `pull_request_target` is genuinely required and safe.
4. No `${{ github.event.* }}` inside `run:`.
5. Third-party actions pinned to SHAs.
6. Secrets scoped to the jobs that use them.
6. Secrets scoped to the jobs that use them, and never passed to a local action on a `pull_request` run.
7. No job both checks out PR code and holds a secret.
32 changes: 7 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,6 @@ jobs:
ON_MISSING: ${{ (env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost') && 'error' || 'previous-commit' }}
run: node scripts/nx-set-shas.js --branch "$BRANCH" --head "$HEAD_COMMIT" --on-missing "$ON_MISSING"

- name: Check user org membership
id: check_user_org_membership
if: github.event_name == 'pull_request'
run: |
echo "Looking up: ${{ github.triggering_actor }}"
ENCODED_USERNAME=$(printf '%s' '${{ github.triggering_actor }}' | jq -sRr @uri)

LOOKUP_USER=$(curl --write-out "%{http_code}" --silent --output /dev/null --location "https://api.github.com/orgs/tryghost/members/$ENCODED_USERNAME" --header "Authorization: Bearer ${{ secrets.CANARY_DOCKER_BUILD }}")

if [ "$LOOKUP_USER" == "204" ]; then
echo "User is in the org"
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "User is not in the org"
echo "is_member=false" >> $GITHUB_OUTPUT
fi

- name: Determine changed packages
if: env.IS_TAG != 'true'
uses: AurorNZ/paths-filter@c9dd42e99db87803313ff6f4b1150cc9f6c836af # v5.0.0
Expand Down Expand Up @@ -325,7 +308,6 @@ jobs:
is_development: ${{ env.IS_DEVELOPMENT }}
is_six: ${{ env.IS_SIX }}
is_six_pr: ${{ env.IS_SIX_PR }}
member_is_in_org: ${{ steps.check_user_org_membership.outputs.is_member }}
has_perf_tests_label: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'perf-tests') }}
node_version: ${{ env.NODE_VERSION }}
node_test_matrix: ${{ steps.node_matrix.outputs.matrix }}
Expand Down Expand Up @@ -593,7 +575,9 @@ jobs:
series: Benchmark
metric: Boot time
title: Boot time (dev tree)
github-token: ${{ secrets.CANARY_DOCKER_BUILD }}
# Only push runs publish, and only they get the token: a pull_request run
# executes PR-authored code, including this local action's own definition.
github-token: ${{ github.event_name != 'pull_request' && secrets.CANARY_DOCKER_BUILD || '' }}

# Boot time of the production image, as a series separate from job_perf-tests:
# that one tracks the code, this one tracks what ships (NODE_ENV=production, no
Expand Down Expand Up @@ -685,7 +669,9 @@ jobs:
series: Production image
metric: Boot time
title: Boot time (production image)
github-token: ${{ secrets.CANARY_DOCKER_BUILD }}
# Only push runs publish, and only they get the token: a pull_request run
# executes PR-authored code, including this local action's own definition.
github-token: ${{ github.event_name != 'pull_request' && secrets.CANARY_DOCKER_BUILD || '' }}

job_unit-tests:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -795,8 +781,6 @@ jobs:
--health-timeout=5s
--health-retries=60
env:
DB: mysql8
NODE_ENV: testing-mysql
COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled }}
name: Acceptance tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8)
steps:
Expand Down Expand Up @@ -902,9 +886,6 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=12
env:
DB: mysql8
NODE_ENV: testing-mysql
name: Legacy tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand Down Expand Up @@ -2344,6 +2325,7 @@ jobs:
# Serialize per-app publishes so two quick main merges can't both compute the
# same next-patch number and collide on npm. Different apps still publish in
# parallel; cancel-in-progress stays false so a queued publish isn't dropped.
environment: npm-release
concurrency:
group: publish-public-app-${{ matrix.package_name }}
cancel-in-progress: false
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
github.event_name == 'workflow_dispatch'
|| (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-'))
)
environment: npm-release
permissions:
contents: read
id-token: write
Expand Down
17 changes: 8 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
name: Release
run-name: "Release — ${{ inputs.bump-type || 'auto' }} from ${{ inputs.branch || 'main' }}${{ inputs.dry-run && ' (dry run)' || '' }}"
run-name: "Release — ${{ inputs.bump-type || 'auto' }} from main${{ inputs.dry-run && ' (dry run)' || '' }}"

on:
schedule:
- cron: '0 15 * * 2' # Tuesday 3pm UTC
workflow_dispatch:
inputs:
branch:
description: 'Git branch to release from'
type: string
default: 'main'
required: false
bump-type:
description: 'Version bump type (auto, patch, minor)'
type: string
Expand Down Expand Up @@ -55,7 +50,11 @@ jobs:
with:
# Deploy key (via ssh-agent) is used for git push — it bypasses
# branch protection and triggers downstream workflows (unlike GITHUB_TOKEN)
ref: ${{ inputs.branch || 'main' }}
#
# Pinned to main, not a dispatch input: this job runs the checked-out
# scripts/release.js with the deploy key and PAT loaded, so a
# releasable ref must be one that went through review.
ref: main
fetch-depth: 0
ssh-key: ${{ secrets.DEPLOY_KEY }}

Expand Down Expand Up @@ -84,8 +83,8 @@ jobs:
env:
# release.js reads these RELEASE_* vars as its arg defaults. inputs.*
# are empty on the schedule trigger, so fall back to the
# scheduled-release defaults (main / auto / no flags).
RELEASE_BRANCH: ${{ inputs.branch || 'main' }}
# scheduled-release defaults (auto / no flags).
RELEASE_BRANCH: main
RELEASE_BUMP_TYPE: ${{ inputs.bump-type || 'auto' }}
RELEASE_DRY_RUN: ${{ inputs.dry-run || 'false' }}
RELEASE_SKIP_CHECKS: ${{ inputs.skip-checks || 'false' }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('AutomationsList', () => {
expect(screen.getByRole('columnheader', { name: 'In progress' })).toBeInTheDocument();
expect(screen.getByText('1,432')).toBeInTheDocument();
expect(screen.getByText('118')).toBeInTheDocument();
expect(screen.getByText('14 days ago')).toHaveAttribute('datetime', '2026-07-21T07:12:00.000Z');
expect(screen.getByText('Jul 21')).toHaveAttribute('datetime', '2026-07-21T07:12:00.000Z');
});

it('renders Never when an automation has no last entry', () => {
Expand Down
5 changes: 2 additions & 3 deletions apps/admin/src/automations/components/automations-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import {
TableHeader,
TableRow,
} from '@tryghost/shade/components';
import { cn, formatNumber } from '@tryghost/shade/utils';
import moment from 'moment';
import { cn, formatNumber, formatTimestamp } from '@tryghost/shade/utils';

const AUTOMATION_DESCRIPTIONS: Record<string, string> = {
'member-welcome-email-free': 'Welcome new free members after they sign up.',
Expand Down Expand Up @@ -145,7 +144,7 @@ const AutomationsList: React.FC<AutomationsListProps> = ({
const statCells = {
lastEntry: {
content: lastEntry ? (
<time dateTime={lastEntry}>{moment(lastEntry).fromNow()}</time>
<time dateTime={lastEntry}>{formatTimestamp(lastEntry)}</time>
) : (
'Never'
),
Expand Down
126 changes: 126 additions & 0 deletions apps/admin/src/render-admin-app-labs.acceptance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';

import {
configResponse,
fakePages,
fakePosts,
fakePostsListScreen,
renderAdminApp,
settingsResponse,
} from '@test-utils/acceptance';
import { composeLabsBootOverrides, type BootOverrides } from '@test-utils/acceptance/boot';
import { postsListScreen } from '@/posts/list/posts-list.screen';

/** Resolve an override's response the way the boot table serves it. */
async function bodyOf(override: BootOverrides[keyof BootOverrides]): Promise<unknown> {
const response = override?.response;
return typeof response === 'function'
? await (response as (request: Request) => unknown)(new Request('https://ghost.test'))
: response;
}

function labsOf(configBody: unknown): Record<string, boolean> {
return (configBody as { config: { labs: Record<string, boolean> } }).config.labs;
}

function labsSettingOf(settingsBody: unknown): Record<string, boolean> {
const { settings } = settingsBody as { settings: Array<{ key: string; value: string }> };
const entry = settings.find(({ key }) => key === 'labs');
expect(entry).toBeDefined();
return JSON.parse(entry!.value) as Record<string, boolean>;
}

describe('labs/boot composition', () => {
it('merges labs into a browseConfig object override without mutating it', async () => {
const config = configResponse();
config.config.hostSettings = { marker: true };
const snapshot = JSON.stringify(config);

const composed = composeLabsBootOverrides(
{ automations: true },
{ browseConfig: { response: config } },
);
const body = await bodyOf(composed.browseConfig);

expect(labsOf(body).automations).toBe(true);
expect((body as { config: { hostSettings: unknown } }).config.hostSettings).toEqual({
marker: true,
});
expect(JSON.stringify(config)).toBe(snapshot);
});

it('wins over the same flag inside the override response', async () => {
const composed = composeLabsBootOverrides(
{ automations: true },
{
browseConfig: { response: configResponse({ labs: { automations: false } }) },
browseSettings: { response: settingsResponse({ labs: { automations: false } }) },
},
);

expect(labsOf(await bodyOf(composed.browseConfig)).automations).toBe(true);
expect(labsSettingOf(await bodyOf(composed.browseSettings)).automations).toBe(true);
});

it('merges labs into a browseSettings override, adding the labs entry when missing', async () => {
const settings = settingsResponse();
settings.settings = settings.settings.filter(({ key }) => key !== 'labs');

const composed = composeLabsBootOverrides(
{ automations: true },
{ browseSettings: { response: settings } },
);

expect(labsSettingOf(await bodyOf(composed.browseSettings)).automations).toBe(true);
});

it('merges labs into a function response', async () => {
const composed = composeLabsBootOverrides(
{ automations: true },
{ browseSettings: { response: () => Promise.resolve(settingsResponse()) } },
);

expect(labsSettingOf(await bodyOf(composed.browseSettings)).automations).toBe(true);
});

it('leaves unrecognized bodies untouched and keeps responseStatus', async () => {
const errors = { errors: [{ message: 'nope' }] };
const composed = composeLabsBootOverrides(
{ automations: true },
{ browseConfig: { response: errors, responseStatus: 422 } },
);

expect(await bodyOf(composed.browseConfig)).toBe(errors);
expect(composed.browseConfig?.responseStatus).toBe(422);
});

it('compiles the canned responses when boot has no override for the entry', async () => {
const composed = composeLabsBootOverrides({ automations: true });

expect(await bodyOf(composed.browseConfig)).toEqual(
configResponse({ labs: { automations: true } }),
);
expect(await bodyOf(composed.browseSettings)).toEqual(
settingsResponse({ labs: { automations: true } }),
);
});
});

describe('renderAdminApp labs + boot', () => {
// postsListReact gates which implementation serves /posts, so the React
// screen appearing proves the flag survived the boot overrides.
it('applies labs flags alongside browseConfig and browseSettings overrides', async () => {
fakePostsListScreen();
fakePosts([]);
fakePages([]);
await renderAdminApp('/posts', {
labs: { postsListReact: true },
boot: {
browseConfig: { response: configResponse() },
browseSettings: { response: settingsResponse() },
},
});

await expect.element(postsListScreen.page('posts')).toBeVisible();
});
});
6 changes: 0 additions & 6 deletions apps/admin/src/settings/advanced/labs/private-features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,6 @@ const features: Feature[] = [
'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls',
flag: 'getHelperDeduplication',
},
{
title: 'React tag details',
description:
'Renders the tag detail screen (/tags/:slug) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.',
flag: 'tagDetailsReact',
},
{
title: 'Member custom fields',
description: 'Let admins create and manage custom field definitions for members',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ function fakeExportDownload() {
}

async function renderWithArchiveHost() {
const config = configResponse({ labs: { selfServeArchives: true } });
(config.config as { hostSettings?: object }).hostSettings = {
...(config.config as { hostSettings?: object }).hostSettings,
const config = configResponse();
config.config.hostSettings = {
export: { webhookUrl: 'https://archives.example.com/generate' },
};
await renderAdminApp('/settings/advanced', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('ContentFieldMapping', () => {
'authors',
'author_emails',
'tags',
'frontmatter',
'Something else',
]);

Expand All @@ -45,6 +46,7 @@ describe('ContentFieldMapping', () => {
authors: 'authors',
author_emails: 'author_emails',
tags: 'tags',
frontmatter: '',
'Something else': '',
});
});
Expand Down Expand Up @@ -106,10 +108,9 @@ describe('ContentFieldMapping', () => {
'custom_template',
'codeinjection_head',
'codeinjection_foot',
'frontmatter',
]);
expect(CONTENT_FIELD_MAPPINGS.map((field) => field.value)).not.toEqual(
expect.arrayContaining(['newsletter_id', 'email', 'tiers', 'id', 'lexical']),
expect.arrayContaining(['frontmatter', 'newsletter_id', 'email', 'tiers', 'id', 'lexical']),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export const CONTENT_FIELD_GROUPS: readonly ContentFieldGroup[] = [
{ label: 'Custom template', value: 'custom_template', required: false },
{ label: 'Code injection head', value: 'codeinjection_head', required: false },
{ label: 'Code injection foot', value: 'codeinjection_foot', required: false },
{ label: 'Frontmatter', value: 'frontmatter', required: false },
],
},
] as const;
Expand Down
Loading
Loading