Skip to content

Publish a per-tenant OpenAPI spec, off #36 and onto main - #70

Merged
blarghmatey merged 7 commits into
mainfrom
openapi-spec-export-main
Sep 22, 2026
Merged

blarghmatey merged 7 commits into
mainfrom
openapi-spec-export-main

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

N/A

Description (What does it do?)

The spec export built in #37 never reached main. #37 merged into
feat/complement-suppression (#36), which is still open, conflicting and 21
commits behind, so nothing it shipped is on main and nothing downstream of it
can start. This takes those two commits off that stack and puts them on current
main, unchanged apart from the Tenant registry conflict (main has gained a
second tenant since).

The rest of the diff is what falls out of running the generator against a main
that has moved on:

One behavior change. learner_id and completion_status were declared
list[X] | None = None, which renders as anyOf: [{type: array}, {type: null}].
openapi-generator's typescript-axios cannot reduce that, so it treats the
parameter as an arbitrary object and spreads it with Object.entries. The
generated client sends ?0=passed&1=certified rather than the repeated
?completion_status=passed&completion_status=certified that FastAPI parses, and
the file does not compile under tsc --strict. Three parameters hit this, across
both tenants.

They are now plain arrays with Query(default_factory=list). All three already
did tuple(learner_id or ()) at the call site, and _shared_predicates gates on
if filters.learner_ids:, so an omitted parameter and an empty list were never
distinguishable to the query layer. The wire format for callers who pass the
parameter is unchanged; what changes is that the schema stops advertising a
null the code never treated as distinct.

test_array_query_params_are_not_nullable sweeps every published parameter for
an anyOf containing an array, so a route nobody has written yet cannot
reintroduce this quietly.

How can this be tested?

Local checks, all passing:

uv run ruff check .
uv run ruff format --check .
uv run mypy src
uv run bin/generate-openapi-spec --check
uv run pytest -q                          # 282 passed

The part CI does not cover is whether the committed spec actually generates a
usable client, since no pipeline consumes it yet. I ran openapi-generator 7.2.0
(typescript-axios) over both committed specs directly:

java -jar openapi-generator-cli-7.2.0.jar generate \
  -i openapi/specs/b2b_learner_records.yaml -g typescript-axios -o /tmp/lr
java -jar openapi-generator-cli-7.2.0.jar generate \
  -i openapi/specs/b2b_dashboard.yaml -g typescript-axios -o /tmp/db
npx tsc --strict --noEmit --skipLibCheck --target es2020 \
  --moduleResolution node --module esnext /tmp/lr/*.ts

Before the parameter change, both tenants' clients failed with:

api.ts(784,59): error TS2769: No overload matches this call.
  Argument of type 'null' is not assignable to parameter of type
  '{ [s: string]: unknown; } | ArrayLike<unknown>'.

at the Object.entries spread. After it, both generate clean and assign the
array to the parameter's own key, which common.ts's setFlattenedQueryParams
appends once per element, giving the repeated form FastAPI expects.

Also confirmed on the generated output: each row model gets its own interface and
each envelope its own (OrgAnalyticsResponseContractUtilization,
LearnerRecordsResponseLearner, and so on), with no collapse to a single untyped
envelope, which was the open question from #37. maxItems: 100 on learner_id
survives the move off the optional form.

Additional Context

Still not done, unchanged from #37, and all of it either outward-facing or
needing a decision:

  1. The ol-analytics-api-clients repo does not exist yet. It now needs two
    generator configs rather than one.
  2. No PIPELINE_CONFIGS entry in ol-infrastructure
    (src/ol_concourse/pipelines/libraries/configuration.py). Still blocked on
    which branch it watches: that field is release for mitxonline and mit-learn,
    and this repo has no release branch.
  3. mit-learn still uses its hand-written frontends/api/src/analytics/types.ts
    and clients.ts, blocked on 1 and 2.

I have not touched #36. Lifting these commits off it leaves it holding only the
anonymization work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6

blarghmatey and others added 6 commits September 22, 2026 12:42
MIT Learn consumes this service through hand-written types and a hand-written
axios client that mirror models.py column for column. That was the right call
for the first cut - nothing here published a client, and blocking the dashboard
on a cross-repo publish pipeline was not worth it - but it means the frontend
drifts silently every time a materialized view gains or renames a column. It
already has: ol-analytics-api#33 made three engagement totals nullable and added
seven columns, and mit-learn's types still say otherwise.

Each tenant is a mounted sub-app, so it owns its own /openapi.json and the root
app's schema contains none of it. `openapi.py` builds the apps through the same
create_app() the server runs and takes each tenant's document from there, with
two fixups that exist because the output is for a client generator rather than
for the tenant's own /docs: paths are re-prefixed with the mount path, since
Starlette strips it before the sub-app sees a request and a client pointed at
the service host would otherwise call URLs that do not exist; and the document
version is pinned rather than read from the package, so a release that changes
no route produces no diff.

Every route now names its own operation_id. That string becomes the generated
client's method name, and FastAPI's default derives one from the function name
and the whole path - `contractUtilizationOrganizationsOrganizationIdContract
UtilizationGet`, renamed whenever the path moves. The tag prefix is also what
keeps the org and contract routers' identically-named panels apart.

Verified rather than assumed, since the org and contract endpoints are
registered in a loop over a table of specs with a runtime-parametrized generic:
openapi-generator v7.2.0 emits a distinct TypeScript interface per row model and
per envelope (no collapse to one untyped OrgAnalyticsResponse), resolves the 3.1
`anyOf: [integer, null]` columns to `number | null`, and the result typechecks
clean under `tsc --strict`. A test asserts the non-collapse so a future
registration change cannot quietly undo it.

The spec is committed because it is a cross-repo interface: ol-infrastructure's
api_clients_pipeline watches openapi/specs/*.yaml on release and publishes the
TypeScript package from it, the same arrangement behind
@mitodl/mitxonline-api-axios. Drift fails CI twice over - as a test, and as a
--check run of the generator, which is the only thing that exercises the
generator at all. openapi-diff.yml comments the changelog on any PR touching a
spec and fails on a breaking change, because breaking one here means breaking a
client someone already shipped.

Still to land before mit-learn can drop its hand-written client: the
ol-analytics-api-clients repo and the PIPELINE_CONFIGS entry pointing at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1
…e the pipeline as future

The base-only spec loop skipped added specs in the changelog and let the
-f guard silently skip deleted specs in the breaking-change check --
deleting a whole published API would have passed CI. Iterate the union
of base and head filenames instead, and fail explicitly on a removed
spec.

Pin oasdiff by digest in both invocations so an upstream image change
can't silently alter breaking-change classification.

The README, generator script, and workflow comment described the
ol-infrastructure client-publishing pipeline as already wired up; none
of it exists yet (no PIPELINE_CONFIGS entry, no release branch, MIT
Learn still on its hand-written client). Reworded to the intended
future state.

Added a test asserting every route's operation_id is explicit: the
existing uniqueness check still passes for a route that fell back to
FastAPI's path-derived default, which is unique but not stable.
…oard's

The spec export was written before the b2b_learner_records tenant existed and
before the dashboard gained /learner-progress, so this is the first generator
run that covers what main actually serves. b2b_learner_records.yaml is new;
b2b_dashboard.yaml picks up the response-model and description changes that
landed in the meantime.

/learner-progress had no explicit operation_id, so FastAPI derived one from
the function name and the whole path
(learner_progress_organizations__organization_id__contracts__contract_id__learner_progress_get),
which renames the generated client method whenever the route moves.
test_operation_ids_are_explicit catches exactly this; naming it
learners_progress_retrieve follows the tag-prefixed convention the other two
routers already use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
`Optional[list[X]] = None` renders as `anyOf: [{type: array}, {type: null}]`.
openapi-generator's typescript-axios cannot reduce that to `Array<X> | null`,
so it falls back to treating the parameter as an arbitrary object and spreads
it with `Object.entries`. The generated call then serializes
`["passed", "certified"]` as `?0=passed&1=certified` instead of the repeated
`?completion_status=passed&completion_status=certified` FastAPI parses, and
the generated file fails `tsc --strict`.

Three parameters are affected: b2b_learner_records' `learner_id` (on
/learners and /enrollments) and `completion_status`, and b2b_dashboard's
`completion_status` on /learner-progress. All three already collapse the two
cases at the call site (`tuple(learner_id or ())`), so an omitted parameter
and an empty list were never distinguishable to the query layer. Declaring
them as plain arrays with `Query(default_factory=list)` makes the schema say
what the endpoints already mean, and gives a fresh list per request rather
than a shared mutable default.

Verified with openapi-generator 7.2.0: both tenants' clients now assign the
array to the parameter's own key, which common.ts's setFlattenedQueryParams
appends once per element, and both typecheck clean under `tsc --strict`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
… is which

The `anyOf: [array, null]` footgun is invisible until someone generates a
client, which nothing in this repo does yet, so the constraint needs to be
written down next to the other two generator constraints.

The tenant now has two YAML files with near-identical names and different
jobs: the hand-written partner draft under docs/openapi/ and the generated
contract under openapi/specs/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
…ariant

The previous commit argued from the call sites that an omitted list filter and
an empty one are the same query. Nothing enforced it, and nothing stopped a
new route from reintroducing `list[X] | None` and quietly breaking the
generated client again.

test_array_query_params_are_not_nullable sweeps every published parameter for
an `anyOf` containing an array, so it covers routes nobody has written yet
rather than the three that exist. The endpoint test pins the behavior half:
omitting both filters adds no predicate and binds only the org and paging
parameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## b2b_dashboard.yaml: added

## b2b_learner_records.yaml: added

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The published error contract is inaccurate, and generator/workflow edge cases can leave obsolete specs or fail fork-based checks.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Publishes per-tenant OpenAPI contracts for future generated TypeScript clients and adds CI drift enforcement.

Changes:

  • Adds generated specifications and a tenant-aware generator.
  • Stabilizes operation IDs and array query schemas.
  • Adds contract validation and OpenAPI diff automation.
File Description
.github/​workflows/​ci.yml Checks generated specs in CI.
.github/​workflows/​openapi-diff.yml Reports and detects breaking API changes.
README.md Documents published contracts.
bin/​generate-openapi-spec Generates or validates tenant specs.
openapi/​specs/​b2b_dashboard.yaml Publishes dashboard API contract.
openapi/​specs/​b2b_learner_records.yaml Publishes learner-record API contract.
pyproject.toml Adds generator dependencies and lint coverage.
src/​ol_analytics_api/​main.py Adds stable tenant names.
src/​ol_analytics_api/​openapi.py Builds and renders tenant specifications.
src/​ol_analytics_api/​tenants/​b2b_dashboard/​routers/​admin.py Adds explicit operation ID.
src/​ol_analytics_api/​tenants/​b2b_dashboard/​routers/​contracts.py Adds generated operation IDs.
src/​ol_analytics_api/​tenants/​b2b_dashboard/​routers/​learners.py Stabilizes operation ID and array schema.
src/​ol_analytics_api/​tenants/​b2b_dashboard/​routers/​organizations.py Adds generated operation IDs.
src/​ol_analytics_api/​tenants/​b2b_learner_records/​routers/​organizations.py Makes repeatable filters non-nullable.
tests/​test_learner_records.py Tests omitted list filters.
tests/​test_lifespan.py Updates tenant fixtures.
tests/​test_openapi_spec.py Tests contract integrity and generator compatibility.
uv.lock Locks new development dependencies.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/openapi-diff.yml
Comment thread src/ol_analytics_api/openapi.py
Comment thread src/ol_analytics_api/main.py
A fork's GITHUB_TOKEN is read-only no matter what the job requests, so both
comment steps fail there. They run before the oasdiff breaking check, so the
whole job dies and the one thing that actually gates the PR never runs. Both
comment steps now skip unless the PR head is in this repo.

Also fixes the "adding a new tenant" example, which still called Tenant with
the old positional signature: copying it would have bound the mount path to
`name` and then raised for the missing factory. The lifespan argument moved
from third to fourth with it.

Reported by Copilot on #70.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
@blarghmatey
blarghmatey merged commit 74db64a into main Sep 22, 2026
8 checks passed
@blarghmatey
blarghmatey deleted the openapi-spec-export-main branch September 22, 2026 18:20
blarghmatey added a commit that referenced this pull request Sep 22, 2026
main picked up openapi-spec-export (#70) after this branch forked, so the
committed spec predates completion_status_counts. Regenerated with
bin/generate-openapi-spec after rebasing onto main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
blarghmatey added a commit that referenced this pull request Sep 22, 2026
… main

Two things #70 changed under this branch.

_partner_header became _partner_token here while #70 added a test that calls
it. Both sides touched different parts of the file, so the rebase merged them
cleanly and the result referenced a helper that no longer exists.

#70 also added pyyaml, which was the only reason the error-code test asserted
against the ErrorCode enum rather than the contract the enum exists to mirror.
It now reads the enum out of docs/openapi/b2b-learner-records-v1.yaml, so a
code added on one side and not the other fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
blarghmatey added a commit to mitodl/ol-infrastructure that referenced this pull request Sep 22, 2026
ol-analytics-api publishes per-tenant OpenAPI specs as of
mitodl/ol-analytics-api#70, but it cuts releases as bare CalVer tags
(2026.9.17.1) and keeps no long-lived release branch, so there is nothing for
the existing `release`-branch topology to watch. This adds an optional
source_repo_tag_regex: when set, the source resource versions on tags
(version_type=tags) instead of on spec changes landing on a branch.

Two things to know about that switch, both in the docstring as well. The git
resource consults `paths` only when versioning on commits, so under a tag
regex every matching tag rebuilds the client whether or not a spec moved --
the "only when the interface changed" property comes from the release cadence
instead of the path filter. `branch` stops applying too, so a matching tag
anywhere in the repo triggers it. The regex is anchored and rejects
hotfix/<sha>, v-prefixed and releases/-prefixed forms; checked against all
eight tags the repo currently has.

The LoadVarStep fix is not tag-specific. It read
.git/refs/heads/<branch>, which does not exist under a detached tag checkout.
.git/ref is written for every version_type and is already what this repo does
elsewhere (k8s_apps/pipeline.py, simple_pulumi/pipeline.py). The mitxonline
and mit-learn pipelines resolve it to the same SHA they read before;
regenerating both shows an unchanged `source` and only this file path moving.

Requires the version_type support added in ol-concourse. Without it the
argument falls through git_repo's **kwargs onto the Resource as a top-level
key, leaving it out of `source` entirely and the tag_regex inert -- it
generates clean and silently versions on commits, so this cannot merge until
that release lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6
blarghmatey added a commit that referenced this pull request Sep 25, 2026
The per-tenant spec from #70 was generated before this branch filled in
the activity columns, so generate-openapi-spec --check failed on the
stale descriptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Szp4MpXds7CmAW3h7KGL6W
blarghmatey added a commit that referenced this pull request Sep 25, 2026
#62)

* feat(b2b_learner_records): serve activity from the learner-records MVs

ol-data-platform#2693 adds last_active_on, days_active and the three
counters to mv_b2b_learner_enrollment, and last_active_on and
courses_in_progress to mv_b2b_learner. The tenant projected all of them as
NULL.

in_progress now also counts activity. mv_b2b_learner.courses_in_progress is
defined that way, so /learners and /enrollments disagree unless the
completion_status CASE matches it.

The counter descriptions said "distinct blocks". The MV sums per-day
distinct counts, so a block used on two days counts twice. The spec also
says activity doesn't move updated_since: record_updated_on can't carry a
day-granular activity date without falling below the partner's cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMuwnLQwHtHuH7aECS7sf3

* fix(b2b_learner_records): regenerate the OpenAPI spec

The per-tenant spec from #70 was generated before this branch filled in
the activity columns, so generate-openapi-spec --check failed on the
stale descriptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Szp4MpXds7CmAW3h7KGL6W

* fix(b2b_learner_records): gate recomputed activity on active enrollments

mv_b2b_learner counts last_active_on and courses_in_progress over active
enrollments only (ol-data-platform#2693), but the contract_id rollup took
them over every enrollment in scope, so ?contract_id= reported activity
from reclaimed seats that the default request hides. They now follow
enrollment_is_active like courses_enrolled, and include_inactive still
widens them to every enrollment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Szp4MpXds7CmAW3h7KGL6W

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
blarghmatey added a commit that referenced this pull request Sep 25, 2026
main picked up openapi-spec-export (#70) after this branch forked, so the
committed spec predates completion_status_counts. Regenerated with
bin/generate-openapi-spec after rebasing onto main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs
blarghmatey added a commit that referenced this pull request Sep 25, 2026
… envelope (#68)

* feat(b2b_dashboard): return per-status counts on the learner-progress envelope

MIT Learn's learner directory renders four summary tiles (Enrollments, Not
started, In progress, Completed) above the learner table. The other three
came from three extra /learner-progress requests with limit=1 and a
completion_status filter, each paying for an org-manager check, a page
query and a full COUNT(*) scan regardless of limit.

This adds completion_status_counts to LearnerProgressResponse, computed as
conditional SUMs alongside the outcomes_withheld_count SUM already in the
count query, so the breakdown costs no extra scan. not_started, in_progress,
passed and certified come from mutually exclusive branches of
_COMPLETION_STATUS, so the buckets never overlap, and consent-withheld rows
land in none of them: the buckets plus outcomes_withheld_count sum to
total_count.

The counts share the response's own WHERE clause, so they narrow along with
search/status/include_inactive filters rather than staying contract-wide.
That's the cheaper option (the count query already has the clause) and
keeps the envelope internally consistent; a contract-wide variant would need
a second, unfiltered aggregate query.

Fixes #67.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs

* test(b2b_dashboard): cover CompletionStatusCounts field descriptions

Extends the manager-facing-description parametrization to the new
CompletionStatusCounts model, so its fields are held to the same
no-field-name-references constraint as LearnerProgress and
LearnerProgressResponse instead of relying on manual inspection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs

* fix(b2b_dashboard): document bucket precedence and fix a test invariant

CompletionStatusCounts field descriptions read as if passed and
in_progress could overlap with certified, but _COMPLETION_STATUS checks
certificate status first: a certified enrollment counts only there even
when it also has a passing grade. Each field's description now says what
it excludes, and the fields are reordered to match the CASE precedence.

test_completion_status_counts_reported_from_the_count_query used
total_count=10 with buckets summing to 10 plus withheld=1, violating the
buckets + outcomes_withheld_count == total_count invariant the endpoint
promises. Derives total_count from the fixture values instead of a
hardcoded one, and asserts the sum invariant directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs

* chore(b2b_dashboard): regenerate OpenAPI spec after rebase

main picked up openapi-spec-export (#70) after this branch forked, so the
committed spec predates completion_status_counts. Regenerated with
bin/generate-openapi-spec after rebasing onto main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1inM1db2mZzptMmC9VJfs

* docs(b2b_dashboard): note why completion_status_counts skips cohort suppression

CompletionStatusCounts is the only b2b_analytics aggregate that never
applies a cohort_policy floor. Document that it's intentional, since the
learner-progress endpoint already exposes the individual matching rows,
so there's nothing left to hide by suppressing the summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfT3zzM5d6LQ5BQvkJhN5N

* chore(b2b_dashboard): regenerate OpenAPI spec for docstring update

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfT3zzM5d6LQ5BQvkJhN5N

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
blarghmatey added a commit to mitodl/ol-infrastructure that referenced this pull request Sep 26, 2026
* feat(api-clients): generate ol-analytics-api's client off release tags

ol-analytics-api publishes per-tenant OpenAPI specs as of
mitodl/ol-analytics-api#70, but it cuts releases as bare CalVer tags
(2026.9.17.1) and keeps no long-lived release branch, so there is nothing for
the existing `release`-branch topology to watch. This adds an optional
source_repo_tag_regex: when set, the source resource versions on tags
(version_type=tags) instead of on spec changes landing on a branch.

Two things to know about that switch, both in the docstring as well. The git
resource consults `paths` only when versioning on commits, so under a tag
regex every matching tag rebuilds the client whether or not a spec moved --
the "only when the interface changed" property comes from the release cadence
instead of the path filter. `branch` stops applying too, so a matching tag
anywhere in the repo triggers it. The regex is anchored and rejects
hotfix/<sha>, v-prefixed and releases/-prefixed forms; checked against all
eight tags the repo currently has.

The LoadVarStep fix is not tag-specific. It read
.git/refs/heads/<branch>, which does not exist under a detached tag checkout.
.git/ref is written for every version_type and is already what this repo does
elsewhere (k8s_apps/pipeline.py, simple_pulumi/pipeline.py). The mitxonline
and mit-learn pipelines resolve it to the same SHA they read before;
regenerating both shows an unchanged `source` and only this file path moving.

Requires the version_type support added in ol-concourse. Without it the
argument falls through git_repo's **kwargs onto the Resource as a top-level
key, leaving it out of `source` entirely and the tag_regex inert -- it
generates clean and silently versions on commits, so this cannot merge until
that release lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6

* feat(api-clients): publish one npm package per spec

ol-analytics-api publishes two specs, one per tenant, and they are
independent APIs: an org-manager dashboard behind a user JWT, and a
machine-to-machine partner integration. A consumer of one has no reason to
pull the other's types in, so they ship as separate packages.

client_repo_subpath now takes a list as well as a string, and the publish job
emits one task per entry. The packages share the repo-root VERSION the bump
step writes, so a release moves them together.

Existing variants pass a string and are normalized to a single-element list,
so mitxonline and mit-learn still emit exactly one publish task against the
same directory as before. Their task name changes from `publish-node` to
`publish-node-<package>`, since two tasks in one plan cannot share a name;
that is a step label in the Concourse UI, not behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6

* fix(api-clients): make the tag regex POSIX, and fail loudly on a stale lib

Two problems with the tag support in the preceding commits, both found in
review.

The regex used `\d`. The resource filters tags with `grep -E`, which is POSIX
ERE, not PCRE: GNU grep emits "stray \ before d", drops the escape, and the
pattern then requires literal `d` characters. It matched none of the eight
tags ol-analytics-api has, so the pipeline would have set up clean and never
fired on a release. My earlier check of this pattern used Python's `re`, which
is the wrong engine and matched all eight. Verified the replacement by piping
every real tag through /usr/bin/grep -E, plus hotfix/<sha>, v- and
releases/-prefixed forms, a three-component version and a -rc1 suffix, none of
which match.

The ordering hazard also had no mechanical guard. An ol-concourse without
version_type support does not reject the argument -- git_repo forwards it
through **kwargs onto the Resource, so it lands as a top-level key, `source`
never gets it, and the resource falls back to versioning commits on `main`
with no paths filter, republishing the client on every commit. Merging this
before the ol-concourse release, or merging it and forgetting to relock, was a
silent failure. Generation now raises if version_type did not reach the
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH3LZ41bD4ZKDnTQcviqb6

* fix(api-clients): give each npm package its own retryable publish job

Sequential publish steps in one job can't recover from a partial
failure: if the first `yarn npm publish` succeeds and a later package
fails, retrying the job re-runs the first publish, which npm rejects
since that name/version already exists. Split the publish job per
subpath, each gated on the same generate-clients run, so a failed
package retries on its own without touching packages that already
published.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps9LGhZZH9g4D4MLpsoNuC

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ol-release-bot ol-release-bot Bot mentioned this pull request Sep 30, 2026
23 tasks done
blarghmatey added a commit that referenced this pull request Sep 30, 2026
… main

Two things #70 changed under this branch.

_partner_header became _partner_token here while #70 added a test that calls
it. Both sides touched different parts of the file, so the rebase merged them
cleanly and the result referenced a helper that no longer exists.

against the ErrorCode enum rather than the contract the enum exists to mirror.
It now reads the enum out of docs/openapi/b2b-learner-records-v1.yaml, so a
code added on one side and not the other fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
blarghmatey added a commit that referenced this pull request Sep 30, 2026
… main

Two things #70 changed under this branch.

_partner_header became _partner_token here while #70 added a test that calls
it. Both sides touched different parts of the file, so the rebase merged them
cleanly and the result referenced a helper that no longer exists.

against the ErrorCode enum rather than the contract the enum exists to mirror.
It now reads the enum out of docs/openapi/b2b-learner-records-v1.yaml, so a
code added on one side and not the other fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants