Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Prefer plain ASCII characters in Markdown unless a typographic character is genu
- 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 <merge-commit>` 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 <tag>` against `git ls-remote --tags origin <tag>`. 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 <tag> | grep <path>`, or `git show <tag>:<file>` — since that cannot be confounded by ancestry or by which tag your clone happens to hold.
- **Patch tags are cut from a release branch, not from `main`, so `--contains` returns nothing for them by design.** `v5.2.5`'s commit diverges from `main` and is not an ancestor of it — the `v5.2` line is its own branch with selective cherry-picks. A feature merged to `main` therefore appears in **no** `v5.2.x` tag however recent that tag is, and shows up only when a release is cut from `main` (the next minor) or someone back-ports the commit. So an empty `--contains` on a main merge means "the next minor", not "nobody has released since" — and a newer patch tag existing is not evidence against it. Check the release branch directly (`git branch -r --contains <sha>`) if you need to know whether a back-port happened.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This backport check cannot detect a selective cherry-pick: cherry-picking creates a new commit SHA, so git branch -r --contains <original-main-sha> remains empty even when the feature is present on the release branch. That can incorrectly assign the next-minor badge to a backported feature. Please recommend inspecting the feature's files on the release branch/tag (as the preceding bullet does) or using patch-equivalence tooling such as git log --cherry instead of an ancestry check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

To make things funner, 1/3 of the time patch releases come from main, 2/3 of the time they come from a release branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and it breaks the bullet in the direction that matters: a cherry-pick creates a new SHA, so git branch -r --contains <original-main-sha> stays empty even when the feature is sitting on the release branch — which would badge a back-ported feature as next-minor, the exact error that bullet exists to prevent.

Rewritten in f55789e9 to ask whether the code is there rather than whether the commit is an ancestor, which is what the preceding bullet already does: git show <tag>:<path/to/feature/file> or git ls-tree -r --name-only <tag> | grep <path>. Added git log --cherry-pick --left-right <tag>...main for when you need the commit rather than the file.

The through-line I had missed: every ancestry-based shortcut in this area has a case that inverts it. File presence at the tag is the only test that survives all of them — which is also what settled the disputed badge on #637.

🤖 Addressed by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That is worse than the version I had, and useful — thank you. My bullet asserted patch tags are cut from a release branch, full stop, which is wrong a third of the time and implies an empty --contains reliably means "next minor".

Fixed in f55789e9: roughly a third come off main and the rest off a release branch, so --contains tells you nothing either way until you know which kind of cut that tag was. Combined with the cherry-pick point on the other thread, ancestry is out entirely — the guidance now sends you to check for the feature's files at the tag.

Which is the more useful rule anyway: it does not require knowing how the release was cut, so it holds for all three cases.

🤖 Addressed by Claude Code

- **Compare commits, not tag objects.** An annotated tag's own SHA differs from the commit it points at, so two people can cite `v5.2.5` as different hashes and both be right. Use `<tag>^{commit}`.
- Each minor release gets a file under `release-notes/<major-codename>/` (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.
Expand Down
10 changes: 5 additions & 5 deletions reference/cli/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ For remote Operations API commands, the CLI uses the first complete authenticati
:::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:

| Context | Use |
| ----------------------- | ----------------------------------- |
| CI/CD pipeline | `HARPER_CLI_REFRESH_TOKEN` |
| A one-off admin command | `auth_username=` / `auth_password=` |
| Local development | `harper login` |
| Context | Use |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
| CI/CD pipeline | [Workload identity](#workload-identity-oidc) where the runtime supports it; otherwise `HARPER_CLI_REFRESH_TOKEN` |
| A one-off admin command | `auth_username=` / `auth_password=` |
| Local development | `harper login` |

Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. One caveat: this rule bounds _which_ credential is used, not what happens when none resolves. A loopback node authorizes a request that arrives with no credential at all as superuser, so the style matters less there than whether a credential is attached — see the refresh-behavior note below.
:::
Expand Down
12 changes: 10 additions & 2 deletions reference/operations-api/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,11 @@ The user the policy names is the privilege boundary: a matching run gets that us
"audience": "https://my-instance.harperdb.io:9925/",
"user": "ci-deploy",
"operations": ["deploy_component", "get_deployment", "restart_service"],
"claims": { "repository_id": "67890", "environment": "production" }
"claims": {
"repository_id": "67890",
"workflow_ref": "HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main",
"environment": "production"
}
}
```

Expand All @@ -735,9 +739,11 @@ A scoped token also cannot trade itself for a browser session: `create_authentic

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.

**An http(s) audience must be the exact string the CI client asks for**, which means an explicit port and a trailing slash — `https://my-instance.example.com:9925/`, not `https://my-instance.example.com`. This is checked for **every** issuer, not only GitHub Actions, and the shorter form is rejected at write time. The audience is matched byte-for-byte at verification, and the CLI requests its token for the normalized target, which supplies `:9925` and the trailing slash — so a policy written without them could never authenticate. An audience that is not an http(s) URL (an `api://` identifier, a bare GUID) is not constrained.

**The audience should identify this instance.** For GitHub Actions, Harper rejects the provider's shared default — anything shaped like `https://github.com/<owner>` — 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.
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_ — the canonical-form rule above still applies — and the required `sub` pin is what binds the policy to one principal.

##### Policy specificity for GitHub Actions

Expand Down Expand Up @@ -810,6 +816,8 @@ Lists every policy, **including disabled ones**, sorted by `id`. **super_user on

Returns `{ "policies": [ ... ] }`. Each entry carries `id`, `issuer`, `audience`, `claims`, `user`, `operations` (`null` when unscoped), `enabled`, `description`, `updated_by`, and timestamps.

An entry may also carry **`invalid_reason`** — set when the policy cannot currently authenticate anyone, because it is malformed or because the user it names was deleted or deactivated after the policy was written. Check for it first when diagnosing a rejected exchange: the exchange will not say what failed, so a policy you expected to match that carries an `invalid_reason` is the fastest explanation available.
Comment thread
dawsontoth marked this conversation as resolved.

#### `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`.
Expand Down