From ab1d543ca2ff36b1479eb0a0e5c4cc109e38cf5a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 25 Aug 2026 12:28:40 -0400 Subject: [PATCH 1/5] docs: OIDC trusted publishing, ops-table anchors, and badge guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts from #599 everything whose upstream is settled, now that harper#1849 has been **closed** — with #2135 and #2301, which were stacked on it — leaving no two-phase surface in main at all. **OIDC trusted publishing** (harper#2173, merged 2026-08-21, awaiting v5.3.0). Unlike the two-phase work this is merged, so the API is fixed and only the release is pending: - `add_oidc_trust` / `list_oidc_trust` / `drop_oidc_trust` (super_user) and the unauthenticated `exchange_oidc_token`, with the replace-not-merge semantics. - The GitHub Actions policy-specificity rules as a table — pin the repository, pin the workflow, gate the ref — with what each leaves open unsatisfied, since the ref gate is stricter than npm's model and is the rule most likely to refuse a real workflow. - The `operations` scope, including that it covers the Operations API and SQL but **not** an application's REST/GraphQL path, so it bounds what CI can administer rather than what data it reaches (harper#2201 tracks that gap). - Single-use tokens with an honest account of the cross-node replay race; one rejection message for every failure with the reason in the `oidc-trust` log; `drop_oidc_trust` not revoking already-issued tokens. - The CLI exchange as precedence entry 7 — ranked below every configured credential, so adding `id-token: write` to a workflow that still sets a token secret does not silently change identity. **Ops-table anchors.** Six user/role rows and seven SSH rows pointed at `#certificate-management`, which documents neither. Repointed to `#users--roles` and `#components`; the five genuine certificate rows are untouched. A pre-existing docs bug with no upstream dependency, which is why it should not have been sitting behind a feature branch. **`AGENTS.md`** gains the rule that produced most of this stack's version churn: derive a badge from core's git tags, not from a feature branch's `package.json`, and re-check every refresh pass. Deliberately left on #599: the deployment status groupings. They read as generic tracking corrections, but they enumerate `staged`, `staging`, `activating`, `reverting`, `extracting` and `loading` — and main's code defines only `pending`, `installing`, `replicating`, `restarting`, `success`, `failed` and `rolled_back`. Extracting them would document statuses that do not exist. --- AGENTS.md | 1 + reference/cli/authentication.md | 43 ++++- reference/cli/operations-api-commands.md | 26 +-- reference/operations-api/operations.md | 194 ++++++++++++++++++++++- release-notes/v5-lincoln/5.3.md | 12 ++ 5 files changed, 257 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ecf2812d..f82b28f7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ Prefer plain ASCII characters in Markdown unless a typographic character is genu - Tag minor-version availability inline: `` for new surface, `` for behavior changes to existing surface. - Derive the version from the core release the change ships in, stripping prerelease suffixes (`5.1.0-beta.1` → `v5.1.0`). +- **Determine that release from the core repo's git tags, not from the feature branch's `package.json`.** A branch reading `5.2.0-beta.3` says which release was open when the branch started, not which one the change lands in — if a release is cut before the feature merges, the badge is silently wrong. Check `git tag --sort=-creatordate` for the newest release, and `git tag --contains ` for whether the change is in one; a merged-but-untagged feature ships in the _next_ version, which may be a minor bump. Re-check on every refresh pass of a long-lived docs PR, because a release cut between passes invalidates a badge that was correct when written. - Each minor release gets a file under `release-notes//` (e.g. `release-notes/v5-lincoln/5.1.md`); the sidebar picks it up automatically. - Absolute links from `release-notes/` (or `learn/`) into current reference docs use the versioned path `/reference/v5/...` — the reference plugin maps the current version to the `v5` URL path. - When documenting a change from a core/pro PR, cross-link the feature PR and the docs PR in both descriptions. diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index f139f527a..19c297317 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -44,7 +44,8 @@ For remote Operations API commands, the CLI uses the first complete authenticati 4. Legacy `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` environment variables 5. `HARPER_CLI_OPERATION_TOKEN` and `HARPER_CLI_REFRESH_TOKEN` environment variables, or their legacy `CLI_TARGET_` equivalents — see [Token credentials for CI/CD](#token-credentials-for-cicd) 6. A token saved by `harper login` -7. `username=` and `password=` operation parameters (legacy fallback) +7. A [workload identity token](#workload-identity-oidc) exchanged with the runtime's OIDC provider (v5.3.0) +8. `username=` and `password=` operation parameters (legacy fallback) :::tip **Configure one credential style per context, not two.** Precedence exists to resolve a conflict, but it resolves it silently, and the ways this page describes for authentication to go wrong all need two styles live at once: a payload `username=`/`password=` pair takes over when a token stops resolving, a blank token variable hands the run to whatever saved login the machine has. Pick one and leave the others unset: @@ -62,7 +63,7 @@ Credentials are resolved as a pair and are never combined across sources. An inc Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. -Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. +Entries 5 through 7 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. @@ -255,6 +256,44 @@ Create a **dedicated CI user** and run `harper login --for-ci` as that user. Tha There is no operation that revokes a refresh token directly. To invalidate one, either run `harper login --for-ci` again as that user — minting overwrites the stored hash, so the previous token stops working — or deactivate the user with `alter_user`. `harper logout` is not a revocation: it deletes your local copy and leaves the server-side hash valid. ::: +##### Workload identity (OIDC) + + + +On a runner that can prove its own identity, the CLI needs **no stored credential at all**. It asks the runtime for an identity token addressed to your instance and trades it for a one-hour operation token. Nothing durable is stored in your CI provider, and there is no 30-day token to rotate. + +Configure the instance to trust the workflow once with [`add_oidc_trust`](../operations-api/operations.md#add_oidc_trust), then grant the token permission in the workflow: + +```yaml +permissions: + id-token: write + contents: read +environment: production +steps: + - run: harper deploy by_ref=true restart=true replicated=true + env: + HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret +``` + +`HARPER_CLI_TARGET` is the only variable the step needs, and it is not sensitive — hence `vars` rather than `secrets`. + +**This ranks below every configured credential**, and above only the legacy `username=`/`password=` payload fallback. Adding `id-token: write` to a workflow that still sets `HARPER_CLI_REFRESH_TOKEN` does not change which identity deploys; the stored token keeps winning. That is deliberate — enabling a new capability should not silently re-point an existing pipeline at a different user. Remove the secret when you want the exchange to take over. + +Note the one case where the exchange _does_ take over: because it outranks the legacy fallback, a command that passes `username=` and `password=` as operation parameters on a runner with no configured credential authenticates as the trust policy's user, not as the pair in the payload. That is the intended reading of those fields — for `add_user` they describe the user being created — but it means a script relying on them as credentials changes identity the moment a policy matches. + +The exchange is attempted only when the runtime actually offers an identity. GitHub Actions sets `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` together on a job that declares `id-token: write`; without both, the CLI falls through to its other credential sources rather than reporting a failure. An unrecognized runtime falls through the same way. + +On success the CLI prints, to stderr, which policy authenticated it and as whom: + +``` +Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9925/... +Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'. +``` + +If Harper rejects the token, the CLI reports that and carries on down the precedence list rather than aborting — the same shape as a failed token refresh, with the same consequences. Usually nothing is left to send, so the operation returns 401. But if the command also passes `username=` and `password=`, the legacy fallback applies them and the operation **succeeds as that user instead**; and against a loopback target a credential-less request is authorized as superuser, so it succeeds with no identity check at all. A policy mismatch can therefore look like a working deploy. Keep payload credentials off a command you expect the exchange to authenticate, and see the [refresh-behavior note](#token-credentials-for-cicd) for the loopback case. + +The server deliberately does not report which check failed — see [`exchange_oidc_token`](../operations-api/operations.md#exchange_oidc_token) — so diagnose with `list_oidc_trust` and the instance's `oidc-trust` log. + #### Method 3: Dedicated Authentication Parameters diff --git a/reference/cli/operations-api-commands.md b/reference/cli/operations-api-commands.md index 23a7f5f70..8d128e8b8 100644 --- a/reference/cli/operations-api-commands.md +++ b/reference/cli/operations-api-commands.md @@ -54,24 +54,24 @@ This is just a brief overview of all operations available as CLI commands. Revie | `sql` | Execute SQL queries | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | | `csv_file_load` | Load data from CSV file | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | | `csv_url_load` | Load data from CSV URL | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | -| `list_users` | List all users | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `add_user` | Create a new user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `alter_user` | Modify user properties | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `drop_user` | Delete a user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `list_roles` | List all roles | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `drop_role` | Delete a role | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | +| `list_users` | List all users | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `add_user` | Create a new user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `alter_user` | Modify user properties | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `drop_user` | Delete a user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `list_roles` | List all roles | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `drop_role` | Delete a role | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | | `create_csr` | Create certificate signing request | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `sign_certificate` | Sign a certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `list_certificates` | List SSL/TLS certificates | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `add_certificate` | Add SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `remove_certificate` | Remove SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `add_ssh_key` | Add SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `get_ssh_key` | Get SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.7.2 | -| `update_ssh_key` | Update SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `delete_ssh_key` | Delete SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `list_ssh_keys` | List all SSH keys | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `set_ssh_known_hosts` | Set SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `get_ssh_known_hosts` | Get SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | +| `add_ssh_key` | Add SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `get_ssh_key` | Get SSH key | [Components](../operations-api/operations.md#components) | v4.7.2 | +| `update_ssh_key` | Update SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `delete_ssh_key` | Delete SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `list_ssh_keys` | List all SSH keys | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `set_ssh_known_hosts` | Set SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `get_ssh_known_hosts` | Get SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 | | `cluster_get_routes` | Get cluster routing information | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | | `cluster_network` | Get cluster network status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | | `cluster_status` | Get cluster status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 9d282b3c8..0adc06b83 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -532,10 +532,14 @@ Operations for JWT token creation and refresh. Detailed documentation: [JWT Authentication](../security/jwt-authentication.md) -| Operation | Description | Role Required | -| ------------------------------ | ------------------------------------------------------- | ---------------------- | -| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) | -| `refresh_operation_token` | Creates a new operation token from a refresh token | any | +| Operation | Description | Role Required | +| ------------------------------ | ---------------------------------------------------------- | ---------------------- | +| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) | +| `refresh_operation_token` | Creates a new operation token from a refresh token | any | +| `exchange_oidc_token` | Trades a CI workload identity token for an operation token | none (unauthenticated) | +| `add_oidc_trust` | Creates or replaces an OIDC trust policy | super_user | +| `list_oidc_trust` | Lists all OIDC trust policies, including disabled ones | super_user | +| `drop_oidc_trust` | Deletes an OIDC trust policy | super_user | ### `create_authentication_tokens` @@ -560,6 +564,188 @@ Creates a new operation token from an existing refresh token. } ``` +### OIDC Trusted Publishing + + + +A CI runner can authenticate to Harper with **no stored credential**. It presents an identity token minted by its own provider; if that token verifies against a stored **trust policy**, Harper returns a one-hour operation token for the user the policy names. This is the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use. + +The alternative is a `HARPER_CLI_REFRESH_TOKEN` secret: a 30-day credential, one per user, that expires on a schedule nobody tracks. A trust policy replaces it with a rule you configure once, and revoke with `drop_oidc_trust`. + +```yaml +permissions: + id-token: write + contents: read +environment: production +steps: + - run: harper deploy by_ref=true + env: + HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret +``` + +No secret at all — `HARPER_CLI_TARGET` is not sensitive. See [CLI Authentication](../cli/authentication.md#workload-identity-oidc) for the client half and where the exchange sits in credential precedence. + +Policies live in the replicated `system.hdb_oidc_trust` table, so configuring one on any node applies cluster-wide. + +#### `add_oidc_trust` + +Creates or replaces a trust policy. **super_user only** — a policy lets an external system authenticate as a Harper user, so granting one is equivalent to handing out a credential. + +```json +{ + "operation": "add_oidc_trust", + "id": "my-app-prod", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://my-instance.harperdb.io:9925/", + "user": "ci-deploy", + "claims": { + "repository_id": "67890", + "workflow_ref": "HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main", + "environment": "production" + } +} +``` + +| Parameter | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `id` | **Required.** Policy identifier, 1–128 characters of letters, numbers, `_`, `-`, and `.`. | +| `issuer` | **Required.** The token issuer (`iss`) this policy trusts. | +| `audience` | **Required.** The audience the token must be addressed to. Should identify **this instance**; enforced for GitHub Actions. | +| `claims` | **Required.** The claim constraints a token must satisfy. At least one, and specific enough for the issuer's profile. | +| `user` | **Required.** The Harper user a matching run authenticates as. Must already exist and be active. | +| `operations` | Narrow the minted token to these operations, 1–100 unique names. Omit for the user's full role — see below. | +| `enabled` | Defaults to `true`. A disabled policy is kept but never matched. | +| `description` | Optional free text, up to 1024 characters. | + +This **replaces** the policy rather than merging into it. A partial update is how an over-broad policy gets created by accident, and the point of `claims` is that every constraint in it was written deliberately. + +##### Narrowing what the token may do (`operations`) + +The user the policy names is the privilege boundary: a matching run gets that user's role. `operations` narrows it further, so one CI user can back several policies that each do less than the role allows: + +```json +{ + "operation": "add_oidc_trust", + "id": "my-app-prod", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://my-instance.harperdb.io:9925/", + "user": "ci-deploy", + "operations": ["deploy_component", "get_deployment", "restart_service"], + "claims": { "repository_id": "67890", "environment": "production" } +} +``` + +It is **narrowing only** — never widening. An operation the role forbids stays forbidden, so the scope cannot be used to grant something the user does not already have. Omit `operations` and the token carries the user's full role. + +Names are validated when the policy is written, against the same registry `add_role` and `alter_role` use, so a typo is rejected there rather than failing later inside CI with nothing to point at. One consequence: an operation a component registered at runtime with `server.registerOperation` is **not** recognized, because that registry is process-local, so a policy naming one is rejected. It fails closed — a rejected policy, never a widened one — and the same gap applies to `add_role` and `alter_role`. + +:::warning +**The scope covers the Operations API and SQL, not the application data path.** It is enforced at the `verifyPerms` / `verifyPermsAST` gate, so a scoped token still carries the role's full table-level CRUD when it reaches an application's REST or GraphQL resources, which authorize through `checkPermission` instead. + +So `operations` bounds what a CI credential can _administer_, not what data it can read or write. If that matters, point the policy's `user` at a role that is itself least-privilege for the data the token can reach, rather than relying on the scope alone. +::: + +A scoped token also cannot trade itself for a browser session: `create_authentication_tokens` with `purpose: "login"` is refused, because a session carries no operation scope and would silently restore the user's full role. + +`user` is resolved at write time. A policy naming a user that does not exist, or one that is inactive, is rejected — otherwise it would fail only at exchange time, inside CI, with nothing to point at. If the named user is a **super_user**, the policy is still created but the response carries a `warning`: any run matching it gains full administrative access. + +**Claim constraints** are exact string matches. A value may be a string, or an array of strings meaning any-of: + +```json +{ "claims": { "repository": "HarperFast/my-app", "ref": ["refs/heads/main", "refs/heads/release"] } } +``` + +A constrained claim that is **absent** from the token fails rather than passes, so a policy cannot be weakened by an issuer that stops emitting a claim. + +**The audience should identify this instance.** For GitHub Actions, Harper rejects the provider's shared default — anything shaped like `https://github.com/` — because that value is shared by every repository under the owner, so accepting it would make a token minted by any of them valid here. + +That check is a guard against the one known-dangerous value, not a proof of correctness: Harper does not compare the audience against its own identity, so an arbitrary or mistyped value is accepted at write time and instead fails to match at exchange time, when the CLI derives the audience from its target URL. Use the instance URL your CI targets. For an issuer with no registered profile the audience is not checked for specificity at all, and the required `sub` pin is what binds the policy to one principal. + +##### Policy specificity for GitHub Actions + +For `https://token.actions.githubusercontent.com`, a policy must satisfy all three of these, each closing a distinct way a policy can be accidentally broad: + +| Requirement | Satisfied by one of | Left open otherwise | +| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| **Pin the repository** | `repository_id`, `repository` | Any repository | +| **Pin the workflow** | `workflow_ref`, `workflow_path`, `job_workflow_ref`, `job_workflow_path` | Any workflow in that repository | +| **Gate the ref** | `workflow_ref`, `ref`, `environment` | Any branch that can be pushed to the repository | + +The ref gate is the one worth understanding, and it is stricter than npm's model. Pinning repository and workflow without also pinning a ref is not safe: anyone who can push a branch can add the trusted workflow to that branch and mint a token. npm accepts that shape and relies on environment protection instead. + +Consequences worth planning around: + +- **`repository_id` is preferred over `repository`** because it is immutable — it survives a repository rename, and is immune to org-name recycling. +- **A tag-triggered release cannot pin `workflow_ref`**, since the tag is unknown when the policy is written. Pin `workflow_path` instead — Harper derives it from `workflow_ref` by removing the ref — and gate on `environment`. +- **`ref_type: tag` is deliberately not accepted as a ref gate.** Anyone with push access can create a tag. +- **`sub` is not accepted as a pin.** It varies by trigger, and its format changed for repositories created after 2026-07-15 (immutable subjects embed owner and repository ids), so a policy pinning it would have to handle two shapes indefinitely. +- **`job_workflow_ref` pins the workflow but does not gate the ref.** In a reusable workflow it names the reusable workflow that ran, not the caller that invoked it, and its `@ref` suffix is that workflow's own branch — constant however it is called. Accepting it as a ref gate would admit any branch of any repository that references the reusable workflow. Pin the workflow with it if you like, then gate the ref with `workflow_ref`, `ref`, or `environment`. +- **`pull_request_target` runs are denied** unless the policy explicitly constrains `event_name`. Such a run executes the base repository's workflow, with its secrets, while a fork controls the checked-out code. A plain `pull_request` run from a fork cannot mint at all, since it gets no `id-token: write`. + +##### Other issuers + +An issuer with no registered profile gets a strict generic profile: the policy must pin **`sub`**. That is the one claim every OIDC issuer defines as identifying a single principal, and it makes workload identity work with no provider-specific code — a Kubernetes service-account token (`system:serviceaccount::`), a GCP service account, and a SPIFFE SVID all carry a stable canonical subject. + +GitHub Actions needs its own profile precisely because its `sub` is the one claim you should _not_ pin. + +#### `exchange_oidc_token` + +Trades an identity token for a Harper operation token. **Unauthenticated by design** — this operation _is_ the authentication, the way `create_authentication_tokens` is against a password. The CLI calls it for you; you would call it directly only from a client that mints its own requests. + +```json +{ + "operation": "exchange_oidc_token", + "token": "eyJhbGciOi..." +} +``` + +Response: + +```json +{ + "operation_token": "eyJhbGciOi...", + "expires_in": 3600, + "username": "ci-deploy", + "policy": "my-app-prod" +} +``` + +The operation token is valid for **one hour** — long enough to cover a slow deploy, short enough to bound the exposure if it leaks. That is a reduced window, not safety: within the hour it is a live credential carrying the policy's identity, so treat it like any other secret and keep it out of logs and step outputs. No refresh token is issued; a subsequent run performs a new exchange. + +:::note +**Every rejection returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not. +::: + +**An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind. + +That replay check is replicated, but replication is asynchronous, so two simultaneous replays against **different nodes** can both succeed. This is not a privilege escalation — whoever holds the token could obtain one operation token regardless — and what it does stop is the realistic case: a token that leaks after a legitimate run and is reused inside its window. + +Exchanges are recorded in the authentication audit stream alongside Basic, Bearer, and mTLS events, for failures as well as successes — a run repeatedly failing to authenticate is what an audit trail is for. Enable it with `logging.auditAuthEvents.logSuccessful` and `logging.auditAuthEvents.logFailed`. + +#### `list_oidc_trust` + +Lists every policy, **including disabled ones**, sorted by `id`. **super_user only** — the policy set names exactly which repository and workflow are worth compromising. + +```json +{ "operation": "list_oidc_trust" } +``` + +Returns `{ "policies": [ ... ] }`. Each entry carries `id`, `issuer`, `audience`, `claims`, `user`, `operations` (`null` when unscoped), `enabled`, `description`, `updated_by`, and timestamps. + +#### `drop_oidc_trust` + +Stops every workflow that matched the policy from exchanging again. **super_user only.** Fails with `404` if no policy has that `id`. + +```json +{ "operation": "drop_oidc_trust", "id": "my-app-prod" } +``` + +:::caution +**This does not revoke operation tokens already issued.** The minted token is a stateless JWT valid until its one-hour expiry, so a token obtained moments before the policy was dropped keeps authorizing for the rest of that hour. + +Dropping the policy is therefore containment against _future_ runs. If you are responding to a suspected compromise rather than doing routine cleanup, also deactivate or re-role the user the policy named (`alter_user`), which is what stops a token that is already in someone's hands. +::: + --- ## Components diff --git a/release-notes/v5-lincoln/5.3.md b/release-notes/v5-lincoln/5.3.md index e75d55055..50eeb68d6 100644 --- a/release-notes/v5-lincoln/5.3.md +++ b/release-notes/v5-lincoln/5.3.md @@ -19,3 +19,15 @@ When `node.hostname` is unset, Harper resolves the identity to the first valid b Replication URLs now also bracket a bare IPv6 literal correctly (`::1` becomes `ws://[::1]:9933`), which the URL parser previously rejected. See [Configuration Options](/reference/v5/configuration/options#node). + +## Security + +### OIDC Trusted Publishing + +A CI runner can now authenticate to Harper with no stored credential. It presents an identity token minted by its own provider, and if that token verifies against a trust policy configured on the instance, Harper returns a one-hour operation token for the user the policy names — the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use. This replaces a 30-day refresh-token secret with a rule you configure once and revoke with `drop_oidc_trust`. + +Policies are managed with `add_oidc_trust`, `list_oidc_trust`, and `drop_oidc_trust` (all super_user), and live in the replicated `system.hdb_oidc_trust` table. A policy pins the issuer, an instance-specific audience, and a set of claim constraints. For GitHub Actions those constraints must pin the repository, pin the workflow, and gate the ref — a policy naming only a repository and workflow is refused, because anyone who can push a branch could otherwise add the trusted workflow to it and mint a token. Issuers with no registered profile must pin `sub`, which makes Kubernetes service accounts, GCP service accounts, and SPIFFE identities work with no provider-specific configuration. + +A policy can also carry an `operations` list, narrowing the minted token to a subset of its user's role on the Operations API and SQL paths — narrowing only, and not covering an application's REST/GraphQL data path, so the policy's user should still be a least-privilege role for the data it can reach. + +Identity tokens are single-use, though the replay record replicates asynchronously, so two simultaneous replays against different nodes can both succeed. Exchanges are recorded in the authentication audit stream when `logging.auditAuthEvents.logSuccessful` / `logFailed` is enabled — they are off by default, so turn them on before you need the trail. Every rejection returns the same message, with the specific reason written to the `oidc-trust` log. In the CLI the exchange ranks below every configured credential, so enabling it does not silently re-point a pipeline that still has a token secret set. See [OIDC Trusted Publishing](/reference/v5/operations-api/operations#oidc-trusted-publishing) and [Workload identity](/reference/v5/cli/authentication#workload-identity-oidc). From a2f41ac96ede8b7dfea397a6f434c0ab1f21ef41 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 25 Aug 2026 12:34:30 -0400 Subject: [PATCH 2/5] docs: copy edits from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from gemini on #637, all correct. Three are commas after introductory phrases ("In a reusable workflow,", "if minting then fails,", "In the CLI,"). The fourth is worth more than a comma: the sentence about a rejected OIDC exchange ran two consequences together with a semicolon, so the loopback case — where a credential-less request is authorized as superuser and the operation succeeds with no identity check at all — was buried mid-sentence behind the payload-credential case. Now its own sentence. --- reference/cli/authentication.md | 2 +- reference/operations-api/operations.md | 4 ++-- release-notes/v5-lincoln/5.3.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 19c297317..827e1fb6d 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -290,7 +290,7 @@ Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9 Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'. ``` -If Harper rejects the token, the CLI reports that and carries on down the precedence list rather than aborting — the same shape as a failed token refresh, with the same consequences. Usually nothing is left to send, so the operation returns 401. But if the command also passes `username=` and `password=`, the legacy fallback applies them and the operation **succeeds as that user instead**; and against a loopback target a credential-less request is authorized as superuser, so it succeeds with no identity check at all. A policy mismatch can therefore look like a working deploy. Keep payload credentials off a command you expect the exchange to authenticate, and see the [refresh-behavior note](#token-credentials-for-cicd) for the loopback case. +If Harper rejects the token, the CLI reports that and carries on down the precedence list rather than aborting — the same shape as a failed token refresh, with the same consequences. Usually nothing is left to send, so the operation returns 401. But if the command also passes `username=` and `password=`, the legacy fallback applies them and the operation **succeeds as that user instead**. And against a loopback target, a credential-less request is authorized as superuser, so it succeeds with no identity check at all. A policy mismatch can therefore look like a working deploy. Keep payload credentials off a command you expect the exchange to authenticate, and see the [refresh-behavior note](#token-credentials-for-cicd) for the loopback case. The server deliberately does not report which check failed — see [`exchange_oidc_token`](../operations-api/operations.md#exchange_oidc_token) — so diagnose with `list_oidc_trust` and the instance's `oidc-trust` log. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 0adc06b83..3d8753a92 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -679,7 +679,7 @@ Consequences worth planning around: - **A tag-triggered release cannot pin `workflow_ref`**, since the tag is unknown when the policy is written. Pin `workflow_path` instead — Harper derives it from `workflow_ref` by removing the ref — and gate on `environment`. - **`ref_type: tag` is deliberately not accepted as a ref gate.** Anyone with push access can create a tag. - **`sub` is not accepted as a pin.** It varies by trigger, and its format changed for repositories created after 2026-07-15 (immutable subjects embed owner and repository ids), so a policy pinning it would have to handle two shapes indefinitely. -- **`job_workflow_ref` pins the workflow but does not gate the ref.** In a reusable workflow it names the reusable workflow that ran, not the caller that invoked it, and its `@ref` suffix is that workflow's own branch — constant however it is called. Accepting it as a ref gate would admit any branch of any repository that references the reusable workflow. Pin the workflow with it if you like, then gate the ref with `workflow_ref`, `ref`, or `environment`. +- **`job_workflow_ref` pins the workflow but does not gate the ref.** In a reusable workflow, it names the reusable workflow that ran, not the caller that invoked it, and its `@ref` suffix is that workflow's own branch — constant however it is called. Accepting it as a ref gate would admit any branch of any repository that references the reusable workflow. Pin the workflow with it if you like, then gate the ref with `workflow_ref`, `ref`, or `environment`. - **`pull_request_target` runs are denied** unless the policy explicitly constrains `event_name`. Such a run executes the base repository's workflow, with its secrets, while a fork controls the checked-out code. A plain `pull_request` run from a fork cannot mint at all, since it gets no `id-token: write`. ##### Other issuers @@ -716,7 +716,7 @@ The operation token is valid for **one hour** — long enough to cover a slow de **Every rejection returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not. ::: -**An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind. +**An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails, the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind. That replay check is replicated, but replication is asynchronous, so two simultaneous replays against **different nodes** can both succeed. This is not a privilege escalation — whoever holds the token could obtain one operation token regardless — and what it does stop is the realistic case: a token that leaks after a legitimate run and is reused inside its window. diff --git a/release-notes/v5-lincoln/5.3.md b/release-notes/v5-lincoln/5.3.md index 50eeb68d6..c7928d4d9 100644 --- a/release-notes/v5-lincoln/5.3.md +++ b/release-notes/v5-lincoln/5.3.md @@ -30,4 +30,4 @@ Policies are managed with `add_oidc_trust`, `list_oidc_trust`, and `drop_oidc_tr A policy can also carry an `operations` list, narrowing the minted token to a subset of its user's role on the Operations API and SQL paths — narrowing only, and not covering an application's REST/GraphQL data path, so the policy's user should still be a least-privilege role for the data it can reach. -Identity tokens are single-use, though the replay record replicates asynchronously, so two simultaneous replays against different nodes can both succeed. Exchanges are recorded in the authentication audit stream when `logging.auditAuthEvents.logSuccessful` / `logFailed` is enabled — they are off by default, so turn them on before you need the trail. Every rejection returns the same message, with the specific reason written to the `oidc-trust` log. In the CLI the exchange ranks below every configured credential, so enabling it does not silently re-point a pipeline that still has a token secret set. See [OIDC Trusted Publishing](/reference/v5/operations-api/operations#oidc-trusted-publishing) and [Workload identity](/reference/v5/cli/authentication#workload-identity-oidc). +Identity tokens are single-use, though the replay record replicates asynchronously, so two simultaneous replays against different nodes can both succeed. Exchanges are recorded in the authentication audit stream when `logging.auditAuthEvents.logSuccessful` / `logFailed` is enabled — they are off by default, so turn them on before you need the trail. Every rejection returns the same message, with the specific reason written to the `oidc-trust` log. In the CLI, the exchange ranks below every configured credential, so enabling it does not silently re-point a pipeline that still has a token secret set. See [OIDC Trusted Publishing](/reference/v5/operations-api/operations#oidc-trusted-publishing) and [Workload identity](/reference/v5/cli/authentication#workload-identity-oidc). From 676b17629902667fd5800e90bd24d751c68b1bbe Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 25 Aug 2026 12:39:22 -0400 Subject: [PATCH 3/5] docs(security): aim the OIDC section at the happy path, add a CLI walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review comments from @dawsontoth. **The YAML block is now labelled GitHub Actions.** `permissions: id-token: write` is Actions-specific syntax, not a portable convention, and the page presented it as though it were generic. It now says so, notes that other CI systems express the same idea differently, and that Harper detects Actions only — with an unrecognized runtime falling through rather than erroring. **Cut the combination-case analysis.** Two paragraphs explained what happens when OIDC is configured *alongside* a refresh token or payload credentials, including which one wins and the one case where the exchange takes over. Accurate, but the wrong thing to hand a first-time reader: it teaches the mixed configuration instead of the good one. Replaced with what someone setting this up needs — with a trust policy you need nothing else, remove a token if you have one, and a pointer to Authentication Precedence for anyone genuinely in a mixed setup. The rejected-exchange paragraph got the same treatment: it keeps the point that matters (a rejection does not reliably halt the command) and drops the payload-credential branching that only applies off the happy path. **Added a CLI walkthrough for `add_oidc_trust`** — `harper login` once, then run the operation against the cluster, then `list_oidc_trust` to confirm. Includes the detail that makes it work in a shell: `claims` must be quoted as one argument because the CLI parses each value as JSON, which is what produces the nested object the operation wants. Verified against `buildRequest` in `bin/cliOperations.ts`, where every value except `ref` is JSON-parsed. Also gives the explicit `target=` + `auth_username=` form for anyone who would rather not store a login token. --- reference/cli/authentication.md | 11 ++++++---- reference/operations-api/operations.md | 30 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 827e1fb6d..78c5d5241 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -262,9 +262,12 @@ There is no operation that revokes a refresh token directly. To invalidate one, On a runner that can prove its own identity, the CLI needs **no stored credential at all**. It asks the runtime for an identity token addressed to your instance and trades it for a one-hour operation token. Nothing durable is stored in your CI provider, and there is no 30-day token to rotate. -Configure the instance to trust the workflow once with [`add_oidc_trust`](../operations-api/operations.md#add_oidc_trust), then grant the token permission in the workflow: +Configure the instance to trust the workflow once with [`add_oidc_trust`](../operations-api/operations.md#add_oidc_trust), then grant the token permission in the workflow. + +The block below is **GitHub Actions** syntax — `permissions: id-token: write` is how Actions specifically opts a job into requesting an identity token. Other CI systems expose the same idea differently, and Harper currently detects GitHub Actions only; an unrecognized runtime is not an error, the CLI simply falls through to its other credential sources. ```yaml +# .github/workflows/deploy.yml permissions: id-token: write contents: read @@ -277,9 +280,9 @@ steps: `HARPER_CLI_TARGET` is the only variable the step needs, and it is not sensitive — hence `vars` rather than `secrets`. -**This ranks below every configured credential**, and above only the legacy `username=`/`password=` payload fallback. Adding `id-token: write` to a workflow that still sets `HARPER_CLI_REFRESH_TOKEN` does not change which identity deploys; the stored token keeps winning. That is deliberate — enabling a new capability should not silently re-point an existing pipeline at a different user. Remove the secret when you want the exchange to take over. +**With a trust policy in place you need nothing else** — no `HARPER_CLI_REFRESH_TOKEN`, no password, no credentials on the command. That is the point of it: the workflow holds no Harper secret at all. -Note the one case where the exchange _does_ take over: because it outranks the legacy fallback, a command that passes `username=` and `password=` as operation parameters on a runner with no configured credential authenticates as the trust policy's user, not as the pair in the payload. That is the intended reading of those fields — for `add_user` they describe the user being created — but it means a script relying on them as credentials changes identity the moment a policy matches. +If your pipeline already sets a token or password, remove it. Leaving it in place is not harmful but it does keep winning — a configured credential outranks the exchange, deliberately, so that enabling this does not silently re-point an existing pipeline at a different user. See [Authentication Precedence](#authentication-precedence) if you need the full order for a mixed setup. The exchange is attempted only when the runtime actually offers an identity. GitHub Actions sets `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` together on a job that declares `id-token: write`; without both, the CLI falls through to its other credential sources rather than reporting a failure. An unrecognized runtime falls through the same way. @@ -290,7 +293,7 @@ Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9 Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'. ``` -If Harper rejects the token, the CLI reports that and carries on down the precedence list rather than aborting — the same shape as a failed token refresh, with the same consequences. Usually nothing is left to send, so the operation returns 401. But if the command also passes `username=` and `password=`, the legacy fallback applies them and the operation **succeeds as that user instead**. And against a loopback target, a credential-less request is authorized as superuser, so it succeeds with no identity check at all. A policy mismatch can therefore look like a working deploy. Keep payload credentials off a command you expect the exchange to authenticate, and see the [refresh-behavior note](#token-credentials-for-cicd) for the loopback case. +If Harper rejects the token, the CLI reports it and carries on rather than aborting. On a workflow configured as above there is nothing left to try, so the operation itself fails with a 401 — but do not treat a rejected exchange as a guaranteed hard stop: if anything else on the runner can authenticate, the command proceeds under that identity instead. [Authentication Precedence](#authentication-precedence) covers those cases. The server deliberately does not report which check failed — see [`exchange_oidc_token`](../operations-api/operations.md#exchange_oidc_token) — so diagnose with `list_oidc_trust` and the instance's `oidc-trust` log. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 3d8753a92..9484a8345 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -617,6 +617,36 @@ Creates or replaces a trust policy. **super_user only** — a policy lets an ext | `enabled` | Defaults to `true`. A disabled policy is kept but never matched. | | `description` | Optional free text, up to 1024 characters. | +##### Setting a policy up from the CLI + +Every Operations API operation is available as a CLI command of the same name, which is usually the easiest way to configure a policy: log in once from your machine, then run the operation against the cluster. + +```sh +# 1. Authenticate to the cluster you are configuring (once, interactively) +harper login https://my-instance.harperdb.io:9925 + +# 2. Create the trust policy +harper add_oidc_trust \ + id=my-app-prod \ + issuer=https://token.actions.githubusercontent.com \ + audience=https://my-instance.harperdb.io:9925/ \ + user=ci-deploy \ + claims='{"repository_id":"67890","workflow_ref":"HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main","environment":"production"}' + +# 3. Confirm what the cluster now trusts +harper list_oidc_trust +``` + +`claims` is a JSON object, so quote it as a single shell argument — the CLI parses each value as JSON, which is what turns that string into the nested object the operation expects. `operations` works the same way if you scope the policy: `operations='["deploy_component","get_deployment"]'`. + +`harper login` stores a token for that target, so step 2 needs no credentials of its own. If you would rather not store one, pass the target and credentials explicitly instead: + +```sh +harper add_oidc_trust target=https://my-instance.harperdb.io:9925 auth_username=HDB_ADMIN auth_password="$ADMIN_PASSWORD" id=my-app-prod ... +``` + +Both routes need **super_user**, since that is what the trust-policy operations require. + This **replaces** the policy rather than merging into it. A partial update is how an over-broad policy gets created by accident, and the point of `claims` is that every constraint in it was written deliberately. ##### Narrowing what the token may do (`operations`) From e9b474ca432d86c6a634836f34ec105c2cee315b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 25 Aug 2026 13:28:09 -0400 Subject: [PATCH 4/5] docs(security): qualify the uniform-rejection claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @cb1kenobi: "every rejection returns the same message" was an overclaim. `exchangeOidcToken` validates the request shape with Joi and throws ClientError(validation.message) before entering the try block that produces the uniform rejectToken 401s, so a missing or oversized `token` field fails with its own message. Now scoped to a well-formed token, with a note that the schema rejection reveals nothing about a policy — which is the property the uniformity exists to protect. --- reference/operations-api/operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 9484a8345..aea164d85 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -743,7 +743,7 @@ Response: The operation token is valid for **one hour** — long enough to cover a slow deploy, short enough to bound the exposure if it leaks. That is a reduced window, not safety: within the hour it is a live credential carrying the policy's identity, so treat it like any other secret and keep it out of logs and step outputs. No refresh token is issued; a subsequent run performs a new exchange. :::note -**Every rejection returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not. +**Every rejection of a well-formed token returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. A malformed request — a missing `token`, or one over 8192 characters — is rejected by schema validation before any of that, with its own message; that reveals nothing about a policy. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not. ::: **An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails, the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind. From 92aa185881c4291fffb35f27e50a2dc2c955aa18 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 25 Aug 2026 13:32:16 -0400 Subject: [PATCH 5/5] docs(agents): verify the tag object before trusting --contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guidance this PR adds is what produced a false Critical on this PR: a review reported git tag --contains returning v5.2.5 for the OIDC merge commit, when four checks against a tag object verified identical to origin say it is not contained there at all. So the check needs the step that distinguishes those cases. Fetch tags, then confirm git rev-parse matches git ls-remote for the same name — --contains is only as good as the local tag it compares against, and a divergent tag of the same name is confidently wrong in either direction. Also records the better decisive check for a disputed badge: whether the feature files exist at the tag (git ls-tree / git show :). That cannot be confounded by ancestry or by which tag a clone holds, and it is what settled this one. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index f82b28f7a..143bcf679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ Prefer plain ASCII characters in Markdown unless a typographic character is genu - Tag minor-version availability inline: `` for new surface, `` for behavior changes to existing surface. - Derive the version from the core release the change ships in, stripping prerelease suffixes (`5.1.0-beta.1` → `v5.1.0`). - **Determine that release from the core repo's git tags, not from the feature branch's `package.json`.** A branch reading `5.2.0-beta.3` says which release was open when the branch started, not which one the change lands in — if a release is cut before the feature merges, the badge is silently wrong. Check `git tag --sort=-creatordate` for the newest release, and `git tag --contains ` for whether the change is in one; a merged-but-untagged feature ships in the _next_ version, which may be a minor bump. Re-check on every refresh pass of a long-lived docs PR, because a release cut between passes invalidates a badge that was correct when written. +- **Fetch tags and confirm the tag object before trusting the answer.** `git tag --contains` is only as good as the local tag it compares against: a stale or divergent tag of the same name gives a confidently wrong answer in either direction. Run `git fetch --tags`, then check `git rev-parse ` against `git ls-remote --tags origin `. When a badge is disputed, the decisive check is not `--contains` at all but whether the feature's files exist at the tag — `git ls-tree -r --name-only | grep `, or `git show :` — since that cannot be confounded by ancestry or by which tag your clone happens to hold. - Each minor release gets a file under `release-notes//` (e.g. `release-notes/v5-lincoln/5.1.md`); the sidebar picks it up automatically. - Absolute links from `release-notes/` (or `learn/`) into current reference docs use the versioned path `/reference/v5/...` — the reference plugin maps the current version to the `v5` URL path. - When documenting a change from a core/pro PR, cross-link the feature PR and the docs PR in both descriptions.