Skip to content

Commit 74703f0

Browse files
ondrejmirtesclaude
andcommitted
Add CDK app for apiref.phpstan.org infrastructure
Mirror the website infra modernization from phpstan/phpstan: replace the click-configured legacy stack for apiref.phpstan.org with code under apigen/infra/. - ApirefStack: private S3 bucket via OAC, CloudFront distribution (HTTP/2+3, TLS 1.2_2021), a single CloudFront Function 2.0 doing the per-version landing-page redirects that the legacy apiref-phpstan-org-viewer-request did (now with `/` -> 2.2.x as the new latest, and 301s instead of 302s for SEO), a Response Headers Policy replacing the shared secure-headers-response, and a DNS-validated ACM cert for apiref.phpstan.org. - OidcRolesStack: phpstan-apiref-infra-deploy role for the new workflow. Reuses the account-wide GitHub OIDC provider (the dist-repo CDK app created it); does not try to create a duplicate. - productionAlias context flag controls whether apiref.phpstan.org is attached to the new distribution. Stays false until the manual cutover detaches the alias from the legacy E37G1C2KWNAPBD; then flipped true. - New .github/workflows/apiref-infra.yml: test -> diff -> deploy gated on needs:[test,diff], OIDC, sticky PR diff comment. Same shape as the main-site website-infra.yml. - apiref.yml: switched off the static APIREF_AWS_* keys to OIDC via vars.APIREF_DEPLOY_ROLE_ARN, vars.APIREF_BUCKET, and vars.APIREF_DISTRIBUTION_ID. Added `!apigen/infra/**` to the path filter so infra-only edits don't trigger a (slow) ApiGen rebuild. After merge, set these repository variables in phpstan/phpstan-src: APIREF_INFRA_DEPLOY_ROLE_ARN (from PhpstanApirefOidcRoles output) APIREF_DEPLOY_ROLE_ARN (from PhpstanApirefWebsite output) APIREF_BUCKET phpstan-apiref-web APIREF_DISTRIBUTION_ID (from PhpstanApirefWebsite output) Full bootstrap + cutover + cleanup runbook in apigen/infra/README.md. Conventions and edit-this-when guide in apigen/infra/CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cbe82a commit 74703f0

16 files changed

Lines changed: 3328 additions & 22 deletions

.github/workflows/apiref-infra.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
2+
3+
name: "API Reference Infra"
4+
5+
on:
6+
workflow_dispatch:
7+
pull_request:
8+
paths:
9+
- '.github/workflows/apiref-infra.yml'
10+
- 'apigen/infra/**'
11+
push:
12+
branches:
13+
- "2.2.x"
14+
paths:
15+
- '.github/workflows/apiref-infra.yml'
16+
- 'apigen/infra/**'
17+
18+
concurrency: apiref-infra
19+
20+
jobs:
21+
test:
22+
name: "Test"
23+
runs-on: "ubuntu-latest"
24+
permissions:
25+
contents: read
26+
27+
steps:
28+
- name: Harden the runner (Audit all outbound calls)
29+
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
30+
with:
31+
egress-policy: audit
32+
33+
- name: "Checkout"
34+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
35+
36+
- name: "Install Node"
37+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
38+
with:
39+
node-version: "22"
40+
cache: "npm"
41+
cache-dependency-path: apigen/infra/package-lock.json
42+
43+
- name: "Install dependencies"
44+
working-directory: ./apigen/infra
45+
run: "npm ci"
46+
47+
- name: "TypeScript check"
48+
working-directory: ./apigen/infra
49+
run: "npm run check"
50+
51+
- name: "Unit tests"
52+
working-directory: ./apigen/infra
53+
run: "npm test"
54+
55+
- name: "CDK synth"
56+
working-directory: ./apigen/infra
57+
run: "npx cdk synth --all --quiet"
58+
59+
deploy:
60+
name: "Deploy"
61+
runs-on: "ubuntu-latest"
62+
needs: test
63+
if: "github.event_name == 'push' && github.ref == 'refs/heads/2.2.x'"
64+
permissions:
65+
id-token: write
66+
contents: read
67+
68+
steps:
69+
- name: Harden the runner (Audit all outbound calls)
70+
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
71+
with:
72+
egress-policy: audit
73+
74+
- name: "Checkout"
75+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
76+
77+
- name: "Install Node"
78+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
79+
with:
80+
node-version: "22"
81+
cache: "npm"
82+
cache-dependency-path: apigen/infra/package-lock.json
83+
84+
- name: "Install dependencies"
85+
working-directory: ./apigen/infra
86+
run: "npm ci"
87+
88+
- name: "Configure AWS credentials"
89+
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
90+
with:
91+
role-to-assume: ${{ vars.APIREF_INFRA_DEPLOY_ROLE_ARN }}
92+
aws-region: us-east-1
93+
94+
- name: "CDK deploy"
95+
working-directory: ./apigen/infra
96+
run: "npx cdk deploy --all --require-approval never"

.github/workflows/apiref.yml

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
- 'src/**'
1212
- 'composer.lock'
1313
- 'apigen/**'
14+
- '!apigen/infra/**'
1415
- '.github/workflows/apiref.yml'
1516

1617
env:
@@ -64,43 +65,38 @@ jobs:
6465
- apigen
6566
if: github.repository_owner == 'phpstan'
6667
runs-on: "ubuntu-latest"
68+
permissions:
69+
id-token: write
70+
contents: read
6771
steps:
6872
- name: Harden the runner (Audit all outbound calls)
6973
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
7074
with:
7175
egress-policy: audit
7276

73-
- name: "Install Node"
74-
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
75-
with:
76-
node-version: "16"
77-
7877
- name: "Download docs"
7978
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
8079
with:
8180
name: docs
8281
path: docs
8382

84-
- name: "Sync with S3"
85-
uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1
83+
- name: "Configure AWS credentials"
84+
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
8685
with:
87-
args: --exclude '.git*/*' --follow-symlinks
88-
env:
89-
SOURCE_DIR: './docs'
90-
DEST_DIR: ${{ github.ref_name }}
91-
AWS_REGION: 'eu-west-1'
92-
AWS_S3_BUCKET: "web-apiref.phpstan.org"
93-
AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }}
94-
AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }}
86+
role-to-assume: ${{ vars.APIREF_DEPLOY_ROLE_ARN }}
87+
aws-region: us-east-1
88+
89+
- name: "Sync with S3"
90+
run: |
91+
aws s3 sync ./docs "s3://${{ vars.APIREF_BUCKET }}/${{ github.ref_name }}" \
92+
--exclude '.git*/*' \
93+
--follow-symlinks
9594
9695
- name: "Invalidate CloudFront"
97-
uses: chetan/invalidate-cloudfront-action@12d242edc7752fca9140c2034be28792ad22c5a8 # v2.4.1
98-
env:
99-
DISTRIBUTION: "E37G1C2KWNAPBD"
100-
PATHS: '/${{ github.ref_name }}/*'
101-
AWS_REGION: 'eu-west-1'
102-
AWS_ACCESS_KEY_ID: ${{ secrets.APIREF_AWS_ACCESS_KEY_ID }}
103-
AWS_SECRET_ACCESS_KEY: ${{ secrets.APIREF_AWS_SECRET_ACCESS_KEY }}
96+
run: |
97+
aws cloudfront create-invalidation \
98+
--distribution-id "${{ vars.APIREF_DISTRIBUTION_ID }}" \
99+
--paths "/${{ github.ref_name }}/*"
104100
105101
- uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
106102
with:

apigen/infra/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
*.js
3+
!functions/*.js
4+
*.d.ts
5+
cdk.out
6+
.cdk.staging
7+
coverage

apigen/infra/CLAUDE.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# apiref.phpstan.org infrastructure (CDK)
2+
3+
AWS CDK app (TypeScript) that defines the production infra for
4+
[apiref.phpstan.org](https://apiref.phpstan.org) — the auto-generated ApiGen
5+
reference for the PHPStan codebase. S3 origin, CloudFront distribution, edge
6+
function for per-version landing-page redirects, security headers policy, ACM
7+
cert, and the IAM roles that GitHub Actions assumes via OIDC.
8+
9+
See `README.md` for the bootstrap, cutover, and cleanup runbook.
10+
11+
This stack mirrors the main-site infra at
12+
[`phpstan-dist`/website/infra](https://github.com/phpstan/phpstan/tree/2.2.x/website/infra)
13+
— same patterns, same conventions; reach for that repo first when looking for
14+
prior art.
15+
16+
## Stacks
17+
18+
Both stacks deploy to `us-east-1` (required for CloudFront + ACM).
19+
20+
| Stack | Defined in | Resources |
21+
| --- | --- | --- |
22+
| `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). |
23+
| `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`. |
24+
25+
`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.
26+
27+
## The `productionAlias` flag
28+
29+
Defined in `cdk.json` under `context`, default `false`.
30+
31+
- `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.
32+
- `true` (post-cutover): distribution carries `apiref.phpstan.org` and uses the ACM cert.
33+
34+
The CDK code generates `Aliases: null` and `ViewerCertificate: null` when `productionAlias: false`. CloudFormation treats both as absent.
35+
36+
## Out-of-band resources
37+
38+
The Route 53 record for `apiref.phpstan.org` is **not** managed by CDK. It was
39+
created/updated out-of-band during the cutover (raw `change-resource-record-sets`),
40+
and CloudFormation cannot UPSERT a record that already exists outside its own
41+
state. Same pattern as apex/www on the main site.
42+
43+
## Edge function
44+
45+
`functions/apiref-version-redirects.js` is the CloudFront Function 2.0 source.
46+
It's a lookup-table version of the legacy `apiref-phpstan-org-viewer-request`
47+
JS 1.0 function — same job: 301-redirect bare version URIs (e.g. `/2.2.x` or
48+
`/2.2.x/`) to that version's landing page (`<version>/namespace-PHPStan.html`),
49+
and `/` to the current "latest" (2.2.x in this migration).
50+
51+
302 → 301 was an intentional change to match the main site's redirects.
52+
53+
The lookup table `VERSION_REDIRECTS` is hand-curated. When a new release branch
54+
is added (say 2.3.x), append three entries: `'/2.3.x'`, `'/2.3.x/'`, both
55+
mapping to `/2.3.x/namespace-PHPStan.html`. If 2.3.x should become the new
56+
latest, also update the `'/'` entry. Then `npm test` ensures the lookup table
57+
size and `/` mapping stay in sync.
58+
59+
The file ends with `if (typeof module !== 'undefined') module.exports = {...}`
60+
so it can be imported by Node-based unit tests. In the CloudFront runtime
61+
`module` is undefined, so the export is silently skipped.
62+
63+
## Project layout
64+
65+
```
66+
apigen/infra/
67+
├── bin/infra.ts # CDK app entrypoint — wires both stacks
68+
├── lib/
69+
│ ├── oidc-roles-stack.ts # IAM role (reuses existing OIDC provider)
70+
│ └── apiref-stack.ts # everything that serves traffic
71+
├── functions/
72+
│ └── apiref-version-redirects.js # CloudFront Function 2.0 source
73+
├── test/
74+
│ ├── apiref-version-redirects.test.ts # Vitest: 25 redirect cases
75+
│ └── apiref-stack.test.ts # Vitest: 11 CDK assertions
76+
├── cdk.json # CDK config + context (incl. productionAlias)
77+
├── package.json
78+
├── tsconfig.json
79+
├── vitest.config.ts
80+
├── README.md # bootstrap + cutover runbook (human-facing)
81+
└── CLAUDE.md # this file
82+
```
83+
84+
## Conventions
85+
86+
Same as the main-site infra:
87+
88+
- **Tabs for indentation** in TS, JSON, and JS files.
89+
- **2-space indent** for YAML workflows.
90+
- **Pin GitHub Actions to commit SHAs** with the version in a trailing comment — matches the repo style and what `step-security/harden-runner` audits.
91+
- **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.
92+
- 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.
93+
- Output exports use the `PhpstanApiref…` prefix.
94+
95+
## Commands
96+
97+
```sh
98+
npm ci # install (run after pulling)
99+
npm run check # tsc --noEmit
100+
npm test # vitest run — 36 tests (redirect fn + stack assertions)
101+
npm run synth # cdk synth --all (no AWS creds needed)
102+
npm run diff # cdk diff --all (needs AWS creds for the target account)
103+
npm run deploy # cdk deploy --all
104+
```
105+
106+
`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`.
107+
108+
## CI
109+
110+
`.github/workflows/apiref-infra.yml` triggers on PRs and pushes that touch
111+
`apigen/infra/**` or the workflow file itself. Three jobs (same as the main
112+
site's `website-infra.yml`):
113+
114+
1. `test``npm ci && npm run check && npm test && npx cdk synth --all` (no AWS creds).
115+
2. `diff` (needs: `test`) — assumes `APIREF_INFRA_DEPLOY_ROLE_ARN` via OIDC, runs `cdk diff --all`, posts a sticky PR comment.
116+
3. `deploy` (needs: `[test, diff]`, only on push to `2.2.x`) — assumes the same role, runs `cdk deploy --all --require-approval never`.
117+
118+
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.
119+
120+
## When to edit what
121+
122+
- **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.
123+
- **Changing security headers**`lib/apiref-stack.ts` (`responseHeadersPolicy` block), not the function.
124+
- **Adding cache behaviors or new functions**`lib/apiref-stack.ts`. Extend `test/apiref-stack.test.ts`.
125+
- **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.
126+
- **Cutover flag**`cdk.json` `context.productionAlias`. Only flip after the cutover script has done its work.
127+
128+
## What lives elsewhere
129+
130+
- The ApiGen tool, theme, and PHP filters — `../` (`apigen/apigen.neon`, `apigen/src/`, `apigen/theme/`).
131+
- The PHP source code that ApiGen reads — `../../src/`.
132+
- The build + publish pipeline — `.github/workflows/apiref.yml`.
133+
- 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?".

0 commit comments

Comments
 (0)