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
96 changes: 96 additions & 0 deletions .github/workflows/apiref-infra.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions

name: "API Reference Infra"

on:
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/apiref-infra.yml'
- 'apigen/infra/**'
push:
branches:
- "2.2.x"
paths:
- '.github/workflows/apiref-infra.yml'
- 'apigen/infra/**'

concurrency: apiref-infra

Check warning

Code scanning / zizmor

insufficient job-level concurrency limits Warning

insufficient job-level concurrency limits

jobs:
test:
name: "Test"
runs-on: "ubuntu-latest"
permissions:
contents: read

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2

Check warning

Code scanning / zizmor

action has a known vulnerability Warning

action has a known vulnerability

Check warning

Code scanning / zizmor

action has a known vulnerability Warning

action has a known vulnerability
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

Check warning

Code scanning / zizmor

credential persistence through GitHub Actions artifacts Warning

credential persistence through GitHub Actions artifacts
Comment on lines +33 to +34

- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: apigen/infra/package-lock.json

- name: "Install dependencies"
working-directory: ./apigen/infra
run: "npm ci"

- name: "TypeScript check"
working-directory: ./apigen/infra
run: "npm run check"

- name: "Unit tests"
working-directory: ./apigen/infra
run: "npm test"

- name: "CDK synth"
working-directory: ./apigen/infra
run: "npx cdk synth --all --quiet"

deploy:
name: "Deploy"
runs-on: "ubuntu-latest"
needs: test
if: "github.event_name == 'push' && github.ref == 'refs/heads/2.2.x'"
permissions:
id-token: write

Check warning

Code scanning / zizmor

permissions without explanatory comments Warning

permissions without explanatory comments
contents: read

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2

Check warning

Code scanning / zizmor

action has a known vulnerability Warning

action has a known vulnerability

Check warning

Code scanning / zizmor

action has a known vulnerability Warning

action has a known vulnerability
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

Check warning

Code scanning / zizmor

credential persistence through GitHub Actions artifacts Warning

credential persistence through GitHub Actions artifacts
Comment on lines +74 to +75

- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: apigen/infra/package-lock.json

- name: "Install dependencies"
working-directory: ./apigen/infra
run: "npm ci"

- name: "Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
with:
role-to-assume: ${{ vars.APIREF_INFRA_DEPLOY_ROLE_ARN }}
aws-region: us-east-1

- name: "CDK deploy"
working-directory: ./apigen/infra
run: "npx cdk deploy --all --require-approval never"
40 changes: 18 additions & 22 deletions .github/workflows/apiref.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- 'src/**'
- 'composer.lock'
- 'apigen/**'
- '!apigen/infra/**'
- '.github/workflows/apiref.yml'

env:
Expand Down Expand Up @@ -64,43 +65,38 @@
- apigen
if: github.repository_owner == 'phpstan'
runs-on: "ubuntu-latest"
permissions:
id-token: write

Check warning

Code scanning / zizmor

permissions without explanatory comments Warning

permissions without explanatory comments
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit

- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "16"

- name: "Download docs"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: docs
path: docs

- name: "Sync with S3"
uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1
- name: "Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
with:
args: --exclude '.git*/*' --follow-symlinks
env:
SOURCE_DIR: './docs'
DEST_DIR: ${{ github.ref_name }}
AWS_REGION: 'eu-west-1'
AWS_S3_BUCKET: "web-apiref.phpstan.org"
AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }}
role-to-assume: ${{ vars.APIREF_DEPLOY_ROLE_ARN }}
aws-region: us-east-1

- name: "Sync with S3"
run: |
aws s3 sync ./docs "s3://${{ vars.APIREF_BUCKET }}/${{ github.ref_name }}" \

Check failure

Code scanning / zizmor

code injection via template expansion Error

code injection via template expansion

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion

Check notice

Code scanning / zizmor

code injection via template expansion Note

code injection via template expansion
--exclude '.git*/*' \
--follow-symlinks

- name: "Invalidate CloudFront"
uses: chetan/invalidate-cloudfront-action@12d242edc7752fca9140c2034be28792ad22c5a8 # v2.4.1
env:
DISTRIBUTION: "E37G1C2KWNAPBD"
PATHS: '/${{ github.ref_name }}/*'
AWS_REGION: 'eu-west-1'
AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }}
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ vars.APIREF_DISTRIBUTION_ID }}" \

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion

Check notice

Code scanning / zizmor

code injection via template expansion Note

code injection via template expansion
--paths "/${{ github.ref_name }}/*"

Check failure

Code scanning / zizmor

code injection via template expansion Error

code injection via template expansion

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion

- uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
with:
Expand Down
7 changes: 7 additions & 0 deletions apigen/infra/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
*.js
!functions/*.js
*.d.ts
cdk.out
.cdk.staging
coverage
133 changes: 133 additions & 0 deletions apigen/infra/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# apiref.phpstan.org infrastructure (CDK)

AWS CDK app (TypeScript) that defines the production infra for
[apiref.phpstan.org](https://apiref.phpstan.org) — the auto-generated ApiGen
reference for the PHPStan codebase. S3 origin, CloudFront distribution, edge
function for per-version landing-page redirects, security headers policy, ACM
cert, and the IAM roles that GitHub Actions assumes via OIDC.

See `README.md` for the bootstrap, cutover, and cleanup runbook.

This stack mirrors the main-site infra at
[`phpstan-dist`/website/infra](https://github.com/phpstan/phpstan/tree/2.2.x/website/infra)
— same patterns, same conventions; reach for that repo first when looking for
prior art.

## Stacks

Both stacks deploy to `us-east-1` (required for CloudFront + ACM).

| Stack | Defined in | Resources |
| --- | --- | --- |
| `PhpstanApirefOidcRoles` | `lib/oidc-roles-stack.ts` | `phpstan-apiref-infra-deploy` IAM role used by `apiref-infra.yml`. **Reuses** the account-wide OIDC provider — does NOT create a new one (IAM rejects duplicates of the same provider URL). |
| `PhpstanApirefWebsite` | `lib/apiref-stack.ts` | Private S3 bucket (OAC, versioned), CloudFront distribution, CF Function 2.0, Response Headers Policy, DNS-validated ACM cert for `apiref.phpstan.org`, and `phpstan-apiref-deploy` IAM role used by `apiref.yml`. |

`bin/infra.ts` is the CDK app entrypoint. It hard-codes the account/region/repo/zone constants and reads one CDK context flag, `productionAlias`, that toggles whether `apiref.phpstan.org` is attached to the distribution.

## The `productionAlias` flag

Defined in `cdk.json` under `context`, default `false`.

- `false` (pre-cutover): distribution has no aliases and no ACM cert attached. CloudFormation can create the distribution without conflict even while the legacy `E37G1C2KWNAPBD` still owns the alias. The distribution serves on its `*.cloudfront.net` domain for pre-cutover testing.
- `true` (post-cutover): distribution carries `apiref.phpstan.org` and uses the ACM cert.

The CDK code generates `Aliases: null` and `ViewerCertificate: null` when `productionAlias: false`. CloudFormation treats both as absent.

## Out-of-band resources

The Route 53 record for `apiref.phpstan.org` is **not** managed by CDK. It was
created/updated out-of-band during the cutover (raw `change-resource-record-sets`),
and CloudFormation cannot UPSERT a record that already exists outside its own
state. Same pattern as apex/www on the main site.

## Edge function

`functions/apiref-version-redirects.js` is the CloudFront Function 2.0 source.
It's a lookup-table version of the legacy `apiref-phpstan-org-viewer-request`
JS 1.0 function — same job: 301-redirect bare version URIs (e.g. `/2.2.x` or
`/2.2.x/`) to that version's landing page (`<version>/namespace-PHPStan.html`),
and `/` to the current "latest" (2.2.x in this migration).

302 → 301 was an intentional change to match the main site's redirects.

The lookup table `VERSION_REDIRECTS` is hand-curated. When a new release branch
is added (say 2.3.x), append three entries: `'/2.3.x'`, `'/2.3.x/'`, both
mapping to `/2.3.x/namespace-PHPStan.html`. If 2.3.x should become the new
latest, also update the `'/'` entry. Then `npm test` ensures the lookup table
size and `/` mapping stay in sync.

The file ends with `if (typeof module !== 'undefined') module.exports = {...}`
so it can be imported by Node-based unit tests. In the CloudFront runtime
`module` is undefined, so the export is silently skipped.

## Project layout

```
apigen/infra/
├── bin/infra.ts # CDK app entrypoint — wires both stacks
├── lib/
│ ├── oidc-roles-stack.ts # IAM role (reuses existing OIDC provider)
│ └── apiref-stack.ts # everything that serves traffic
├── functions/
│ └── apiref-version-redirects.js # CloudFront Function 2.0 source
├── test/
│ ├── apiref-version-redirects.test.ts # Vitest: 25 redirect cases
│ └── apiref-stack.test.ts # Vitest: 11 CDK assertions
├── cdk.json # CDK config + context (incl. productionAlias)
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── README.md # bootstrap + cutover runbook (human-facing)
└── CLAUDE.md # this file
```

## Conventions

Same as the main-site infra:

- **Tabs for indentation** in TS, JSON, and JS files.
- **2-space indent** for YAML workflows.
- **Pin GitHub Actions to commit SHAs** with the version in a trailing comment — matches the repo style and what `step-security/harden-runner` audits.
- **No `module.exports` / ESM imports in `functions/*.js`** — they run in the CloudFront Function runtime, not Node. The only allowed exception is the `typeof module` guard for unit-test interop.
- Resource IDs in CDK use **PascalCase**. Resource *names* (`bucketName`, `roleName`, `functionName`, `responseHeadersPolicyName`) use **kebab-case** with the `phpstan-apiref-` prefix so they're easy to spot in the console.
- Output exports use the `PhpstanApiref…` prefix.

## Commands

```sh
npm ci # install (run after pulling)
npm run check # tsc --noEmit
npm test # vitest run — 36 tests (redirect fn + stack assertions)
npm run synth # cdk synth --all (no AWS creds needed)
npm run diff # cdk diff --all (needs AWS creds for the target account)
npm run deploy # cdk deploy --all
```

`npm test` is the gate before any deploy — the CI workflow runs `check` + `test` + `synth` in a `test` job and blocks `diff` and `deploy` on it via `needs: test`.

## CI

`.github/workflows/apiref-infra.yml` triggers on PRs and pushes that touch
`apigen/infra/**` or the workflow file itself. Three jobs (same as the main
site's `website-infra.yml`):

1. `test` — `npm ci && npm run check && npm test && npx cdk synth --all` (no AWS creds).
2. `diff` (needs: `test`) — assumes `APIREF_INFRA_DEPLOY_ROLE_ARN` via OIDC, runs `cdk diff --all`, posts a sticky PR comment.
3. `deploy` (needs: `[test, diff]`, only on push to `2.2.x`) — assumes the same role, runs `cdk deploy --all --require-approval never`.

The `apiref.yml` workflow (the actual content deploy) uses `paths-ignore` via the inline `!apigen/infra/**` form so infra-only edits don't kick off a (slow) ApiGen rebuild.

## When to edit what

- **New release branch** (need a `/X.Y.x` → `/X.Y.x/namespace-PHPStan.html` redirect) → add three entries to `VERSION_REDIRECTS` in `functions/apiref-version-redirects.js` plus three test cases in `test/apiref-version-redirects.test.ts`. If it's the new latest, update `'/'` too.
- **Changing security headers** → `lib/apiref-stack.ts` (`responseHeadersPolicy` block), not the function.
- **Adding cache behaviors or new functions** → `lib/apiref-stack.ts`. Extend `test/apiref-stack.test.ts`.
- **Changing the trust policy** (e.g. allowing another branch to deploy) → `lib/oidc-roles-stack.ts` for infra deploys, or `lib/apiref-stack.ts` for the content deploy role.
- **Cutover flag** → `cdk.json` `context.productionAlias`. Only flip after the cutover script has done its work.

## What lives elsewhere

- The ApiGen tool, theme, and PHP filters — `../` (`apigen/apigen.neon`, `apigen/src/`, `apigen/theme/`).
- The PHP source code that ApiGen reads — `../../src/`.
- The build + publish pipeline — `.github/workflows/apiref.yml`.
- The main-site (`phpstan.org`) infra — separate repo `phpstan/phpstan` (the "dist" repo), under `website/infra/`. Identical patterns; consult it first when wondering "how did we solve X for the main site?".
Loading
Loading