Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Check every text file out with LF everywhere. All tracked files are already LF, so this changes nothing today.
* text=auto eol=lf

# `pnpm generate:types` writes this file with LF, so a CRLF checkout would make every regeneration rewrite all 20k
# lines. `linguist-generated` also collapses it in diffs: what a reviewer reads is `src/models.ts`, which declares
# the published models on top of it.
src/generated/api.ts linguist-generated=true
2 changes: 2 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ jobs:
- run: pnpm lint
- name: Type-check tests
run: pnpm tsc-check-tests
- name: Type-check maintainer scripts
run: pnpm tsc-check-scripts
- name: Format check
run: pnpm format:check

Expand Down
256 changes: 256 additions & 0 deletions .github/workflows/regenerate_types.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Keeps `src/generated/api.ts` in sync with the published OpenAPI specification: every night it regenerates the
# types and opens a pull request when the result differs from master. Renovate cannot do this -- the specification
# is a live document, not an npm dependency -- so without this job the generated types never move and none of the
# drift guards in `src/spec_guards.ts` can ever fire.
#
# Two invariants make it safe to run unattended:
# 1. Generation always happens on master, so the output follows the current specification and the current codegen
# tooling, never whatever an older auto-update branch carries. Scheduled runs only ever start from the default
# branch anyway; the explicit ref is what keeps a manual dispatch honest.
# 2. The auto-update branch is rebuilt from master instead of appended to, so the diff is always "current
# specification vs current master" and cannot resurrect a stale generated file.
#
# Deliberately not automerged, and no build step here: the pull request is opened with a token that triggers
# `check.yaml`, so whether the adapter in `src/models.ts` still compiles against the new types is answered by that
# run. A red check is the signal `src/spec_guards.ts` exists for -- a field an override replaces was dropped or
# renamed, a documented spec gap was filled, or a shared `@apify/consts` enum diverged -- and each of those needs a
# human to decide.

name: Regenerate types

on:
workflow_dispatch:

schedule:
- cron: '0 2 * * *'

concurrency:
group: regenerate-types
cancel-in-progress: false

# Writes go through the service account token below, not through `GITHUB_TOKEN`.
permissions:
contents: read

env:
NODE_VERSION: 24
BRANCH_NAME: ci/regenerate-types
# Valid Conventional Commits, so the pull request is mergeable as-is; reviewers retitle it to `fix:`/`feat:`
# when the diff is user-facing.
PR_TITLE: 'chore: regenerate types from the published OpenAPI spec'
ASSIGNEE: vdusek
LABEL: t-tooling
# The only file the gate looks at. The recorded specification version is committed alongside it but
# deliberately excluded: that stamp moves on every apify-docs deploy regardless of client impact.
GENERATED_FILE: src/generated/api.ts

jobs:
regenerate-types:
name: Regenerate types
runs-on: ubuntu-latest

steps:
- name: Checkout master
uses: actions/checkout@v7
with:
ref: master
token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
fetch-depth: 0

- name: Use Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}

- name: Install pnpm and dependencies
uses: apify/actions/pnpm-install@v1.4.0

# Read before regeneration overwrites it, so the pull request can say whether the specification moved.
- name: Read the recorded specification version
id: previous-spec
run: |
# Assigned first: inside `echo "...$(...)"` a failing read would be swallowed and the step
# would report an empty version.
version=$(node scripts/openapi_spec.mts recorded-version)
echo "version=$version" >> "$GITHUB_OUTPUT"

# Downloads the specification, generates from it, and records its version in `package.json`.
- name: Regenerate types
run: pnpm generate:types

# Gate on the generated types, not on the specification version: that stamp is a coarse marker
# rather than a content identity, for the reasons `scripts/openapi_spec.mts` sets out. Compared
# against HEAD rather than the index, so nothing staged earlier in the job can hide a change.
- name: Check for type changes
id: changes
run: |
if git diff --quiet HEAD -- "$GENERATED_FILE"; then
echo "Types are already up to date with the published specification."
echo "has-changes=false" >> "$GITHUB_OUTPUT"
else
git diff --stat HEAD -- "$GENERATED_FILE"
echo "has-changes=true" >> "$GITHUB_OUTPUT"
fi

# A previous run may already have these exact types up for review; leave it alone instead of churning
# an open pull request. Only the generated file is compared - the branch being behind master says
# nothing about whether the types on it are still the right ones.
- name: Check whether the types are already up for review
id: review
if: steps.changes.outputs.has-changes == 'true'
env:
GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
run: |
# `--exit-code` reports 2 for "no such branch"; any other failure must not read as an absent
# branch, or a transient network error would retire a current pull request.
git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1 && status=0 || status=$?
if [[ "$status" == "2" ]]; then
echo "No auto-update branch exists yet."
echo "is-new=true" >> "$GITHUB_OUTPUT"
exit 0
elif [[ "$status" != "0" ]]; then
echo "Failed to look up branch $BRANCH_NAME on the remote (git ls-remote exited $status)." >&2
exit 1
fi

# The branch only counts as proposed while a pull request is open on it: a leftover branch - a run
# that failed before opening one, or a review that closed it without deleting the branch - must be
# rebuilt, or its contents would look proposed and silently suppress every future regeneration. An
# empty result counts only once the query itself succeeded, otherwise an API error would replace a
# live pull request.
if ! PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --state open --json number --jq '.[0].number // empty'); then
echo "Failed to list open pull requests for $BRANCH_NAME." >&2
exit 1
fi

if [[ -z "$PR_NUMBER" ]]; then
echo "Branch $BRANCH_NAME has no open pull request - rebuilding it."
echo "is-new=true" >> "$GITHUB_OUTPUT"
exit 0
fi

git fetch origin "$BRANCH_NAME"
if git diff --quiet FETCH_HEAD -- "$GENERATED_FILE"; then
echo "The open pull request already carries these types - nothing to do."
echo "is-new=false" >> "$GITHUB_OUTPUT"
else
echo "is-new=true" >> "$GITHUB_OUTPUT"
fi

# Retire the previous branch before recreating it. Deleting it first also avoids the window where the
# branch tip would equal master, which GitHub reads as an empty pull request and auto-closes.
- name: Retire the superseded pull request
if: steps.changes.outputs.has-changes == 'true' && steps.review.outputs.is-new == 'true'
env:
GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --state open --json number --jq '.[0].number // empty')

if [[ -n "$PR_NUMBER" ]]; then
gh pr close "$PR_NUMBER" --delete-branch \
--comment "Superseded by a newer regeneration run: ${RUN_URL}. The branch is rebuilt from master rather than appended to, so a commit pushed here by hand is not carried over - it stays reachable from this pull request and has to be re-applied on the new one."
echo "Closed superseded PR #${PR_NUMBER}."
elif git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then
git push origin --delete "$BRANCH_NAME"
echo "Deleted leftover branch $BRANCH_NAME (no open pull request)."
fi

# Creates the branch at the current master commit and lands the regenerated file as one signed
# ("Verified") commit, via GitHub's createCommitOnBranch mutation.
- name: Commit the regenerated types
id: commit
if: steps.changes.outputs.has-changes == 'true' && steps.review.outputs.is-new == 'true'
uses: apify/actions/signed-commit@v1.4.0
with:
message: ${{ env.PR_TITLE }}
add: >-
package.json
${{ env.GENERATED_FILE }}
github-token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
branch: ${{ env.BRANCH_NAME }}
create-branch: 'true'

- name: Create the pull request
if: steps.commit.outputs.committed == 'true'
env:
GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/regenerate_types.yaml
PREVIOUS_SPEC_VERSION: ${{ steps.previous-spec.outputs.version }}
run: |
SPEC_VERSION=$(node scripts/openapi_spec.mts recorded-version)

# A moved stamp proves the specification changed; an unchanged one proves nothing. Say which
# case this is instead of letting the reviewer read it as a content identity.
if [[ "$SPEC_VERSION" == "$PREVIOUS_SPEC_VERSION" ]]; then
SPEC_LINE="- Specification version: \`${SPEC_VERSION}\` - **unchanged**, which doesn't rule out a specification change: the published stamp can lag its content by a deploy. Only the diff tells."
else
SPEC_LINE="- Specification version: \`${PREVIOUS_SPEC_VERSION}\` -> \`${SPEC_VERSION}\`."
fi

BODY=$(printf '%s\n' \
"- Regenerates \`src/generated/api.ts\` from the [published OpenAPI specification](https://docs.apify.com/api/openapi.json), and records its version in \`package.json\`." \
"${SPEC_LINE}" \
"" \
"> [!IMPORTANT]" \
"> Retitle this pull request to \`fix:\` or \`feat:\` when the diff is user-facing, so that it lands in the changelog and triggers a release - \`chore:\` does neither." \
"" \
"> [!NOTE]" \
"> A red check here is a guard in \`src/spec_guards.ts\` doing its job: a field an override block replaces was dropped or renamed, a documented spec gap was filled and its \`*SpecGaps\` entry is now stale, or a shared \`@apify/consts\` enum diverged. Each of those is a decision, not a mechanical update." \
"" \
"> Generated by the [Regenerate types](${WORKFLOW_URL}) workflow.")

gh pr create \
--title "$PR_TITLE" \
--body "$BODY" \
--base master \
--head "$BRANCH_NAME" \
--assignee "$ASSIGNEE" \
--label "$LABEL"

# Without this a broken sync stops regeneration silently: GitHub only notifies whoever last touched the cron,
# and no release waits on this workflow. Skipped on manual dispatch so ad-hoc triggers don't spam the channel.
notify_on_failure:
name: Notify Slack on failure
needs: regenerate-types
if: failure() && github.event_name == 'schedule'
runs-on: ubuntu-latest

steps:
- name: Build Slack payload
env:
REPO: ${{ github.repository }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
HEADING: ':red_circle: Nightly type regeneration failed'
run: |
jq -n \
--arg repo "${REPO}" \
--arg url "${WORKFLOW_URL}" \
--arg heading "${HEADING}" \
'{
text: "\($heading) in \($repo)",
blocks: [
{
type: "header",
text: { type: "plain_text", text: $heading, emoji: true }
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "*Repository:*\n\($repo)" },
{ type: "mrkdwn", text: "*Workflow run:*\n<\($url)|View on GitHub>" }
]
},
{
type: "section",
text: { type: "mrkdwn", text: "The generated API types are no longer being kept in sync with the published OpenAPI specification." }
}
]
}' > slack-payload.json

- name: Send Slack notification
uses: slackapi/slack-github-action@v4.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload-file-path: slack-payload.json
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"**/dist",
"coverage",
"website",
"docs"
"docs",
"src/generated"
]
}
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ src/
├── http_client.ts # Low-level HTTP layer (Axios-based)
├── apify_api_error.ts # Custom error class
├── utils.ts # Utility functions
├── generated/
│ └── api.ts # Types generated from the OpenAPI specification (do not edit)
├── base/
│ ├── api_client.ts # Base for all clients
│ ├── resource_client.ts # Base for single-resource clients
Expand All @@ -70,6 +72,11 @@ test/
└── mock_server/ # Mock API server for testing
├── server.ts
└── routes/ # Mock API routes

scripts/
├── openapi_spec.mts # Downloads the published OpenAPI specification
├── generate_types.mts # Generates src/generated/api.ts from it
└── spec_transform.mts # Spec postprocessing the generator applies
```

### Key Patterns
Expand Down Expand Up @@ -99,6 +106,11 @@ npm run clean # Remove dist directory
# Testing
npm test # Build and run vitest suite
npm run tsc-check-tests # TypeScript check test files
npm run tsc-check-scripts # TypeScript check maintainer scripts

# API specification (needs Node 22.18+, for native TypeScript support)
npm run generate:types # Regenerate src/generated/api.ts from the published specification
npm run spec:fetch # Only download the specification, into git-ignored tmp/

# Linting & Formatting
npm run lint # ESLint check
Expand Down
9 changes: 8 additions & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from '@apify/oxlint-config';

export default defineConfig({
ignorePatterns: ['**/node_modules', '**/dist', 'coverage', 'website', '**/*.d.ts'],
ignorePatterns: ['**/node_modules', '**/dist', 'coverage', 'website', '**/*.d.ts', 'src/generated'],
rules: {
'typescript/no-explicit-any': 'off',
'consistent-return': 'off',
Expand All @@ -17,6 +17,13 @@ export default defineConfig({
'import/no-default-export': 'off',
},
},
{
// Maintainer-facing CLI scripts, so reporting progress on stdout is the point.
files: ['scripts/**'],
rules: {
'no-console': 'off',
},
},
{
files: ['test/**'],
rules: {
Expand Down
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"homepage": "https://docs.apify.com/api/client/js/",
"files": [
"dist",
"!dist/*.tsbuildinfo"
"!dist/*.tsbuildinfo",
"!dist/spec_guards.*"
],
"scripts": {
"build": "pnpm clean && pnpm build:node && pnpm build:browser",
Expand All @@ -61,10 +62,18 @@
"lint": "oxlint --type-aware",
"lint:fix": "oxlint --type-aware --fix",
"tsc-check-tests": "tsc --noEmit --project test/tsconfig.json",
"tsc-check-scripts": "tsc --noEmit --project tsconfig.scripts.json",
"format": "oxfmt",
"format:check": "oxfmt --check",
"build:node": "tsc",
"build:browser": "rsbuild build"
"build:browser": "rsbuild build",
"spec:fetch": "node scripts/openapi_spec.mts fetch",
"generate:types": "node scripts/openapi_spec.mts fetch && node scripts/generate_types.mts && node scripts/openapi_spec.mts record-version"
},
"apify": {
"openapiSpec": {
"version": "v2-2026-08-03T111309Z"
}
},
"dependencies": {
"@apify/consts": "^2.50.0",
Expand Down Expand Up @@ -99,6 +108,7 @@
"esbuild": "0.28.1",
"express": "^5.0.0",
"gen-esm-wrapper": "^1.1.2",
"openapi-typescript": "7.13.0",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"oxlint-tsgolint": "7.0.2001",
Expand Down
Loading
Loading