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
130 changes: 125 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,152 @@ name: Deploy to GitHub Pages
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write
pull-requests: write

concurrency:
group: "pages"
cancel-in-progress: false

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Validate openapi.yaml is valid YAML
run: npm run lint:spec
- name: Run tests
run: npm test

build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build public and full specs
run: npm run build
# dist-internal/openapi.json has every field, with x-internal markers stripped. Uploaded as a
# private Actions artifact only — never published to Pages. Requires repo access
# to download (this repo is private); hand it to Aplifisa manually.
- name: Upload full spec (private, for Aplifisa)
uses: actions/upload-artifact@v4
with:
name: openapi-full
path: dist-internal/openapi.json
retention-days: 90
# Single self-contained HTML file per spec: Scalar UI + spec embedded inline, no
# fetch, no other files. Open directly in a browser — nothing to serve, nothing to
# convert. (Previously rendered to PDF via Puppeteer, but printing an SPA hits page
# breaks that cut content mid-element; HTML has no pages, so nothing to cut.)
- name: Build public and full standalone HTML docs
run: |
npm run build-html -- openapi.yaml openapi-public.html
npm run build-html -- dist-internal/openapi.json openapi-full.html
- name: Upload public HTML doc
id: upload-public-html
uses: actions/upload-artifact@v4
with:
name: openapi-public-html
path: openapi-public.html
# Same access restriction as the openapi-full JSON artifact above — internal-only
# fields included (x-internal markers stripped), never published to Pages.
- name: Upload full HTML doc (private, for Aplifisa)
id: upload-full-html
uses: actions/upload-artifact@v4
with:
name: openapi-full-html
path: openapi-full.html
retention-days: 90
# Deep-links straight to each artifact (.../artifacts/{id}) instead of just the run —
# still requires repo access to download (private repo), same as before. Upserts via
# the marker so repeated pushes update one comment instead of piling up a new one.
- name: Comment PR with doc links
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
PUBLIC_ARTIFACT_ID: ${{ steps.upload-public-html.outputs.artifact-id }}
FULL_ARTIFACT_ID: ${{ steps.upload-full-html.outputs.artifact-id }}
with:
script: |
const marker = '<!-- openapi-doc-comment -->'
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const publicUrl = `${runUrl}/artifacts/${process.env.PUBLIC_ARTIFACT_ID}`
const fullUrl = `${runUrl}/artifacts/${process.env.FULL_ARTIFACT_ID}`
const body = [
marker,
'**API docs for this run are ready** (single self-contained HTML file each — download and open in a browser):',
`- [openapi-public-html](${publicUrl}) — public spec, what ships to the docs site`,
`- [openapi-full-html](${fullUrl}) — full spec including \`x-internal\` (Aplifisa) fields`
].join('\n')

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
})
const existing = comments.find((c) => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
})
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
})
}
# Everything below here is what actually goes public — remove build tooling and
# the full spec/PDFs from disk first so the Pages artifact can only ever contain
# the filtered openapi.yaml, regardless of what upload-pages-artifact does or
# doesn't exclude on its own. Only bundled on push to main — no point producing a
# Pages artifact that nothing will deploy from a PR run.
- name: Strip build tooling and internal spec before publishing
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: rm -rf node_modules dist-internal openapi-public.html openapi-full.html
- name: Setup Pages
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/configure-pages@v5
- name: Upload artifact
- name: Upload Pages artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: '.'

deploy:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist-internal/
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,59 @@ npx serve .

Edit `openapi.yaml` and push to `main`. The docs will be deployed automatically.

## Internal-only fields (`x-internal`)

Some API v1 fields only exist for a specific integration partner (e.g. Aplifisa) and are gated
server-side in the Rails app — they only appear in a response when the owner has that integration
activated (`owner.aplifisa?` in the serializers, passed down from the API v1 controllers as
`with_aplifisa_data`). This repo is public documentation, so those partner-gated fields must never
ship in the published spec (CI removes every `x-internal: true` field before deploying). Any field
that *does* ship publicly (i.e. not `x-internal`) must never name the partner integration by name in
its description — that would leak the association even though the field itself stays public.

**Rule of thumb:** if a field is only serialized `if: -> { instance_options[:with_aplifisa_data] }`
(or the equivalent gate) in `app/serializers/api/v1/**` in the main app, tag it here:

```yaml
some_field:
x-internal: true
type: string
description: >
Whatever it does. (Aplifisa integration only.)
Comment thread
dmartingarcia marked this conversation as resolved.
```

Fields that are *always* returned regardless of any integration flag (even if they were added
alongside integration work) are NOT `x-internal` — document them normally, without naming any
partner.

### How it's built

`openapi.yaml` (committed, with `x-internal` tags visible) is the single source of truth. CI
(`.github/workflows/deploy.yml`) runs `scripts/filter-internal.js` before publishing, which produces
two artifacts from it:

- **Public spec** — every `x-internal`-tagged property removed entirely (not hidden, not present).
This overwrites `openapi.yaml` in the ephemeral CI checkout right before the GitHub Pages upload —
the committed source file is never touched. Formatting of this generated file will differ from the
committed source (it's a machine-dumped copy); don't diff the two for style, only for content.
- **Full spec** (`dist-internal/openapi.json`) — every field present, `x-internal` markers stripped.
Uploaded as a private GitHub Actions artifact (`openapi-full`, 90-day retention) — never published
to Pages. This repo is private, so downloading it requires repo access. Whoever manages the Aplifisa
integration downloads it from the workflow run and sends it to Aplifisa directly.

Both a CI job (`test`, runs on every push and PR) and local `npm test` run
`scripts/filter-internal.test.js`, which includes a check against the real `openapi.yaml` — so a
field with a bad tag, a missing tag, or a stray partner-name leak into a public description fails CI
before it can reach `main`.

```bash
npm install
npm test # unit tests for the filter script, incl. a check against the real spec
npm run build # writes the filtered openapi.yaml + dist-internal/openapi.json (do this in CI, not locally —
# it overwrites your local openapi.yaml with the machine-formatted public version)
npm run lint:spec # quick YAML-validity check
```

## Future improvements

- Auto-generate `openapi.yaml` from request specs using `rspec-openapi`
Expand Down
Loading
Loading