Skip to content

feat(forgejo): Forgejo provider for self-hosted instances - #1425

Draft
wyattjoh wants to merge 9 commits into
alchemy-run:mainfrom
wyattjoh:wyattjoh/add-forgejo-provider
Draft

feat(forgejo): Forgejo provider for self-hosted instances#1425
wyattjoh wants to merge 9 commits into
alchemy-run:mainfrom
wyattjoh:wyattjoh/add-forgejo-provider

Conversation

@wyattjoh

@wyattjoh wyattjoh commented Sep 1, 2026

Copy link
Copy Markdown

Adds a Forgejo provider for self-hosted Forgejo instances. It talks to Forgejo's REST API directly, no vendor SDK, so it works against any instance reachable with a token.

Quick start

import * as Alchemy from "alchemy";
import * as Forgejo from "alchemy/Forgejo";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";

export default Alchemy.Stack(
  "Repos",
  { providers: Forgejo.providers(), state: Alchemy.localState() },
  Effect.gen(function* () {
    const repo = yield* Forgejo.Repository("api", {
      owner: "acme",
      name: "api",
      private: true,
      autoInit: true,
      topics: ["typescript", "effect"],
    });

    yield* Forgejo.Secrets({
      owner: "acme",
      repository: "api",
      secrets: { DEPLOY_TOKEN: Redacted.make(process.env.DEPLOY_TOKEN!) },
    });

    return { url: repo.htmlUrl };
  }),
);

Provider surface

API Purpose
Repository Creates, adopts, renames, and converges repository settings and topics
Organization Organizations, via Forgejo's admin endpoints
Team / TeamMember Org teams, their permission units, and membership
Label Repository issue and pull-request labels
BranchProtection Protected-branch rules, approvals, status checks, push allowlists
Secret / Secrets Actions secrets, scoped to a repository, organization, or user
Variable / Variables Actions variables, same three scopes
Webhook Repository webhooks
ApiToken Mints a Forgejo access token, exposed as a Redacted output

Repositories and organizations default to the retain removal policy, matching the GitHub provider. Renaming a repository (same logical ID, new name) renames in place; changing owner replaces.

Auth

Forgejo is registered as an auth provider via makeStoredAuthProvider, and added to the built-in registry in Alchemist/Session.ts. Because Forgejo is self-hosted, the instance URL is part of the credential rather than a constant, so the provider collects and stores it next to the token:

alchemy profile edit --add Forgejo --method stored \
  --set baseUrl=https://git.example.com \
  --set token=<access token>

alchemy profile edit --add Forgejo connects an instance interactively. providers() resolves from the selected profile, falling back to FORGEJO_URL / FORGEJO_TOKEN in CI. Passing providers({ baseUrl, token }) pins both in code and bypasses profile resolution.

A stored token is the only method here. Forgejo does support custom OAuth2 applications (public client + PKCE), but its OAuth2 tokens are unscoped and carry full administrative rights, where a personal access token can be limited to write:repository. An oauth method can be added later as a second variant, the way CloudflareAuthConfigSchema unions stored and oauth.

Client

Every API failure is a tagged error rather than an HTTP status check, so lifecycle code branches on Effect.catchTag:

export type ForgejoError =
  | ForgejoNotFound
  | ForgejoUnauthorized
  | ForgejoForbidden
  | ForgejoConflict
  | ForgejoValidationError
  | ForgejoServerError
  | ForgejoRequestError
  | ForgejoTransportError;

The credential layers resolve HttpClient from the environment rather than constructing one, so providers() leaves it in RIn (satisfied by StackServices) and a caller can substitute a different implementation:

Forgejo.providers({ baseUrl, token }).pipe(Layer.provide(myHttpClient))

List endpoints paginate through paginate() rather than taking Forgejo's default 30-entry first page.

Differences from the GitHub provider

  • Forgejo accepts Actions secrets as plaintext over authenticated TLS. There is no public-key encryption handshake, so every deploy writes the value.
  • Organizations, teams, and ApiToken go through Forgejo's /admin endpoints and need an administrator credential.
  • No repository event source, Webhook only.

Docs

New /forgejo hub (overview, setup, repositories, Actions config, organizations & teams), registered in the sidebar, docs tabs, provider directory, and icon map.

Adds repositories, organizations, teams, team members, labels, branch
protection, Actions secrets and variables, webhooks, and API tokens,
backed by a tagged-error REST client that paginates list endpoints.

Registers Forgejo as an auth provider, so `alchemy profile edit --add
Forgejo` connects an instance. Because Forgejo is self-hosted, the
stored credential carries the instance URL alongside the token.
`providers()` resolves from the selected profile, falling back to
FORGEJO_URL / FORGEJO_TOKEN in CI.

The credential layers resolve HttpClient from the environment rather
than constructing one, so callers can substitute an implementation.

Includes the /forgejo docs hub and its sidebar, tab, icon, and provider
directory registrations.
@wyattjoh
wyattjoh marked this pull request as ready for review September 1, 2026 05:04
@wyattjoh
wyattjoh marked this pull request as draft September 1, 2026 05:24
- Resolve repository identity from the stable numeric ID in `read`,
  `reconcile`, and `delete`. A rename whose state write failed left
  `olds.name` stale, so `delete` 404'd — swallowed by `optional` —
  and leaked the repository while dropping its state row.
- Lift bulk secret values through their `Input` wrapper instead of
  casting to `Redacted`, so a `Config`/`Effect`/`Output` value resolves
  before it is wrapped. Extracted GitHub's `liftValue` to
  `Util/redacted.ts` and shared it.
- Observe webhooks by delivery URL when no ID is known, so a create
  whose state write failed adopts the existing hook rather than adding
  a duplicate on every retry.
- Skip the repository settings PATCH and topics PUT when live state
  already matches, so an unchanged deploy issues no writes.
- Fail with `ForgejoPaginationLimit` instead of silently truncating an
  enumeration that exceeds the page cap.
- Compare Actions scopes structurally rather than by serialization, so
  moving from the legacy repository props to an explicit `scope` no
  longer plans a needless replacement.
- Make `MissingGeneratedToken` a `Data.TaggedError`, guard `output` in
  the webhook and API-token deletes, and wrap the secret timestamp in
  `Effect.sync`.
- Correct the docs: teams and memberships use the organization
  endpoints, not `/admin`.

Adds mock coverage for repository rename, delete after an out-of-band
rename, no-op deploys, the bulk `Secrets`/`Variables` helpers including
an Output-valued secret, and webhook adoption by URL.
…ists

Verified the provider's request and response shapes against Forgejo's
published OpenAPI spec, which turned up three defects:

- `Organization` carries no `html_url` (only `Repository` does), so
  `OrganizationAttributes.htmlUrl` was typed `string` but always
  `undefined` at runtime. It is now derived from the instance the client
  points at. The mock had invented the field, so no test caught it.
- `alchemy nuke` passes the Attributes shape as `olds`, so a resource
  whose Attributes omit its parent identity built
  `/repos/undefined/undefined/...`, 404'd, and had the not-found
  swallowed — reporting success while the resource survived. Webhook,
  Label, and BranchProtection attributes now carry `owner`/`repository`
  and their deletes address the resource from `output` alone.
- A push whitelist is inert unless `enable_push`/`enable_push_whitelist`
  are set, which the provider never sent — the documented "limit pushes
  to a team" example produced a rule that restricted nobody. Declaring a
  whitelist now enables it, overridable via the new `enablePush` and
  `enablePushWhitelist` props.

Webhook adoption now matches on delivery URL *and* event set. Matching
on URL alone collapsed two sibling webhooks that legitimately share a
URL with different events onto one hook, each deploy overwriting the
other.

Adds `NukeContract.test.ts`, which drives `Nuke.ts`'s exact delete call
shape for all three affected resources, and a webhook test covering two
hooks that share a URL.
- `paginate` stopped at the first page shorter than the requested limit.
  Forgejo clamps `limit` to the instance's `[api] MAX_RESPONSE_ITEMS`,
  so on a server whose administrator lowered that below 50 every full
  page looks short and enumeration ended after page one, reporting a
  partial list as complete — the exact outcome `ForgejoPaginationLimit`
  exists to prevent. It now stops only on an empty page.
- `WebhookProps.url` was declared `Input<string>`, which AGENTS.md
  forbids: the Resource machinery applies `Input` deeply already, so the
  annotation double-wrapped and forced two `as string` casts to undo.
  Declared plain; both casts deleted.
- Added `owner`/`repository` to the webhook, label, and branch-protection
  `stables`, since a change to either already forces a replacement.

The mocks served their whole list for every page, which the old
short-page rule masked. They now slice by `page`/`limit` through a shared
`jsonList` helper, so enumeration terminates the way it does against a
real instance, and pagination is covered for a clamped page size.
Checked each remaining call against Forgejo's OpenAPI spec:

- Forgejo has no `/user/actions/secrets` collection endpoint, only
  `{secretname}` PUT/DELETE, so the user-scoped sweep 404'd on every
  `list()` and was swallowed. Dropped it, with a comment saying why the
  scope is absent rather than leaving code that reads as if it works.
- `/branch_protections` accepts no `page`/`limit` and returns every rule,
  so paginating it just cost an extra request. Reads it directly now.
- The org-create and branch-protection-create race catches keyed on
  `ForgejoConflict`, but neither endpoint returns 409 — a duplicate
  arrives as 403/422. Both now catch the tags actually declared.
- `EditHookOption` has no `type`; only `CreateHookOption` does. Split the
  create and edit bodies. The old test asserted the wrong-by-schema shape.

Organization, Team, Label, and BranchProtection now skip their PATCH when
live state already matches, via a shared `matchesDesired`. Adoption of an
unchanged resource issues no write at all.

Replacing an API token deletes it before minting the new one, so the
trigger no longer fires on a reordered `scopes` or `repositories` list.

Narrowed `visibility` and `permission` to their API enums, documented the
create-only repository props and the webhook secret that cannot be
cleared by omission, and listed `ForgejoPaginationLimit` in the docs.
The widened create-race catch covered genuine failures too: a credential
that is not an instance administrator gets the same 403 as a duplicate,
so the recovery ran, its `GET /orgs/{org}` 404'd because nothing had been
created, and the user saw `ForgejoNotFound` instead of `ForgejoForbidden`
— the clearest diagnosis replaced by the most misleading one.

The recovery now only takes over when the organization actually exists,
and otherwise re-fails with the original error.

BranchProtection needs no equivalent change: an unauthorized create falls
through to a PATCH that fails with the same tag, so the right error
already propagates.
Validated the provider against a live Forgejo 16.0.3 instance for the
first time; every earlier round checked it against the published OpenAPI
spec, which a running server disagrees with in four places.

Deleting an organization that still owns repositories fails. Forgejo
answers it with `500 {"message":"user still has ownership of
repositories [uid: N]"}` — a dependency violation wearing a server-error
status. The engine deletes independent resources concurrently, so an
organization racing its own repositories loses often enough that
`destroy` fails outright and only succeeds on a re-run. That body now
maps to a `ForgejoDependencyViolation` tag, which the organization
reconciler retries until the repositories are gone.

None of the client errors carried a message, so the failure above
surfaced as `ForgejoServerError:` and nothing else — no method, path,
status, or body to diagnose it from. Each class now renders the request
it failed on, as `ForgejoPaginationLimit` already did.

`enable_push_whitelist` cannot be true while `enable_push` is false;
Forgejo stores false regardless, on create and on edit alike. Declaring
`enablePushWhitelist: true` alongside `enablePush: false` therefore asked
for a state the instance will never hold, so every deploy observed drift
and re-issued the same rejected edit forever. The body builder now
mirrors the server's own rule, while still leaving an omitted prop
unmanaged.

`matchesDesired` was documented as guarding against archived
repositories, on the grounds that Forgejo rejects edits to them. It does
not: a `PATCH` to a repository with `archived: true` returns 200 and
applies the change. The guard stands on its real merit — not writing when
nothing changed — and the false rationale is gone.

Two inferences the live instance confirmed, and which are unchanged: the
create-race catches match what Forgejo returns (a duplicate organization
422, a duplicate repository 409, a duplicate branch-protection rule 403),
and `GET /repositories/{id}` follows a rename, so deleting by observed id
resolves the live name.

The regression tests were checked against reverted fixes to confirm they
fail without them.
…hooks

Three lifecycle paths that corrupted or silently failed to converge live
state. Each is covered by a test confirmed to fail without its fix.

`ApiToken` reconciled a lost state row into an unusable loop. It only
looked for an existing token when `output` was set, so a create that
succeeded while its state write did not left the next deploy minting the
same name again. Forgejo returns a token's secret once, at creation, so
the live token can be neither read back nor adopted, and Forgejo refuses
a second token under the name — the stack could never converge again. The
collision is now detected before creating and reported as
`UnrecoverableApiToken`, which names the token and what to do about it.
The live token is left alone: it may still be in use, and this provider
cannot tell whether it created it.

An organization's `owner` change replaced a resource with itself. The
login identifies an organization globally, so the replacement's create
observed the existing organization and adopted it right back, reporting
success for a transfer that never happened; with removal opted in, the
old generation's delete then removed the organization the new state
pointed at. Forgejo exposes no ownership-transfer endpoint, so `diff` now
replaces only on a changed login and `reconcile` rejects the transfer
with `UnsupportedOwnerChange`.

Webhook adoption matched on delivery URL and event set alone, which two
resources may legitimately share. The second then adopted the first's
hook, leaving one live hook behind two state rows with each deploy
undoing the other — on a first deploy, not only after state loss.
Forgejo 16.0.3 accepts two hooks with the same URL and events, and
reports `active`, `branch_filter`, and `config.content_type` on every
hook it lists, so the match now covers the full declared identity.

Two review findings were not acted on.

An organization renamed out of band was reported as leaking, on the
grounds that `read` and `delete` address it by a stale login. Forgejo
keeps a redirect from the old login and the client follows it: driving
the real client through a rename, `GET`, `PATCH`, and `DELETE` on the
stale login all resolved to the correct organization, and it was gone
afterwards. There is nothing to fix.

Enumeration was reported as missing organization-owned resources.
`/user/repos` is documented as listing repositories the user owns, but in
practice returns organization-owned ones too whenever the credential is a
member, so the only gap is organizations it is not a member of. Closing
that means enumerating `/admin/orgs`, and these lists are what
`alchemy unsafe nuke` deletes — with no resource tags in Forgejo to
narrow such a list back down, that would put every unrelated user's
organizations in a nuke's path. Under-reporting a resource is
recoverable; deleting a stranger's organization is not. The boundary is
documented in `Lists.ts` instead.
Comment thread packages/alchemy/src/Forgejo/Client.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs to be deleted ands implemented as a generated SDK in https://github.com/alchemy-run/distilled and patched to include tagged errors. We don't accept providers without this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cc @Mkassabov who can help with generating SDKs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Opened a PR over here alchemy-run/distilled#541, will update this one once it's merged and available.

Replace the hand-written Forgejo HTTP client with @distilled.cloud/forgejo,
generated from Forgejo's Swagger document and patched with the tagged
errors the lifecycle code branches on (NotFound, Forbidden, Conflict,
UnprocessableEntity, and OrganizationOwnsRepositories for the 500 an
organization delete answers while it still owns repositories).

Every resource now calls the typed operations directly. Credentials are
the SDK's `Credentials` service, built from the profile, the environment,
or an explicit `{ baseUrl, token }`; `providers()` re-exports the
`HttpClient` it is built with so a test can point every request at an
in-memory instance. Page walking stays alchemy-side, since Forgejo
signals the last page only through a response header.
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