Gitlab Token
Create, rotate, revoke and inventory GitLab personal, project and group access tokens through one model, with a threshold-gated rotation workflow and the one-time token value written to a vaulted sensitive field
Fixes the valid-target preflight, which failed any instance that relied on a documented default. This closes the known issue published with 2026.08.29.2.
The minimal valid instance failed its own check. An instance setting only token — taking every documented default — produced two errors: tokenScope "undefined" requires namespace and baseUrl "undefined" must be an absolute http(s) URL. The tokenScope one sent you in a circle, because setting a namespace then trips namespace is set but tokenScope is personal. Neither message was about the real problem: neither field had been read.
A swamp check runs against the instance YAML as written, BEFORE the schema applies its defaults, so an omitted field arrives as undefined. This check compared those against literals, and its parameter was typed as the parsed globals type, in which both fields are required and present — a lie the compiler could not catch. Every existing test spread a fixture that supplied both fields by hand, so a green suite coexisted with a check that failed the minimal real instance.
The three defaults are now exported constants used by both the schema and the check, so the two cannot drift, and the check's parameter is typed to mark every defaulted field optional — the compiler now insists on a fallback at each read. The new tests pass raw objects rather than spreading the fixture, and one asserts that the schema's parsed defaults ARE those constants, which is the invariant rather than a restatement of it.
Verified by execution: { token } now yields an empty error list, { token, tokenScope: "project" } still yields exactly one, and a malformed baseUrl that was actually supplied still yields its own — the fix must not turn the check into a no-op.
Nothing to migrate. If you wrote baseUrl or tokenScope out explicitly to work around this, you can drop them or keep them; both are correct.
Also in this release: three new exported constants (DEFAULT_BASE_URL, DEFAULT_TOKEN_SCOPE, DEFAULT_TIMEOUT_MS), and a README Pre-flight section that states the check reads the instance as written and lists the tokenId/tokenName rejection it has enforced since 2026.08.29.1 without documenting.
Global Arguments
| Argument | Type | Description |
|---|---|---|
| token | string | GitLab token used to authenticate these API calls, sent as PRIVATE-TOKEN. |
| baseUrl | string | GitLab instance base URL, without the /api/v4 suffix. Override for self-managed. |
| tokenScope | enum | Which token family this instance manages: personal, project, or group. |
| namespace? | string | Project path (group/repo) or group path owning the token, URL-encoded |
| tokenId? | string | ID of the token this model manages. Also accepts the literal `self` to |
| tokenName? | string | Name of the token this model manages, resolved to its live ID on every |
| userId? | string | User ID to create a personal access token for. Requires instance |
| timeoutMs | number | Abort any single API request after this long. |
| Argument | Type | Description |
|---|---|---|
| name | string | Name of the new token. |
| scopes | array | Scopes to grant, e.g. [api] or [read_repository, read_registry]. A |
| expiresAt? | string | Expiry date as YYYY-MM-DD. Omit to take the instance's maximum allowable |
| description? | string | Free text carried on the token, up to 255 characters. |
| accessLevel | enum | Role the token acts as. Project and group tokens only; ignored for personal. |
| Argument | Type | Description |
|---|---|---|
| expiresAt? | string | Expiry date for the replacement token as YYYY-MM-DD. Omit to take |
| when | boolean | Rotate only when true. Exists because swamp workflows cannot express |
| Argument | Type | Description |
|---|---|---|
| when | boolean | Revoke only when true. Same rationale as the argument of the same name |
| Argument | Type | Description |
|---|---|---|
| state | enum | Filter by token state. `all` omits the filter and returns both. |
| search? | string | Filter by token name. |
| expiresBefore? | string | Only tokens expiring before this date (YYYY-MM-DD). Use to find what is |
Resources
Keep one GitLab access token ahead of its own expiry: check what it has left every day, and rotate it only when a human has said to and it is actually close to lapsing. The default posture is deliberate. `rotate` defaults to FALSE, so the scheduled run is a read-only check that keeps the token's `daysRemaining` current and does nothing else. Rotation is irreversible the instant it lands — GitLab revokes the outgoing value immediately, and replaying it trips reuse detection which revokes the whol
Workflow-only release. No model code changed; two defects in the shipped workflow did.
It no longer schedules itself. 2026.08.29.1 shipped a trigger:, so installing the extension registered a daily 06:40 job on every host — at a time this author picked, against whatever model instance matched the hardcoded name gitlab-token. On the first host to install it, that instance existed and was managing a production token, and the vendored job landed ten minutes before the operator's own rotation schedule. The cadence belongs to the operator. Set one with swamp workflow trigger set @sntxrr/gitlab-token-rotation --schedule "40 6 * * *" --input rotate=true --input ciVariableProject=group/repo --input ciVariableKey=GITLAB_PUSH_TOKEN, and read the README's new Scheduling section first: both defaults are safe rather than useful, and passing only some of the inputs fails quietly in two different directions.
Propagation is no longer skipped exactly when it is needed. The propagate step gated on the same days-remaining threshold as rotate — but by the time it runs, rotate has already overwritten the managed snapshot with the NEW token, which has a full lifetime ahead of it. The threshold was therefore false precisely when the handover was required: the outgoing value revoked, the replacement never published, and the run reporting success. The gate is now current.observedAt == managed.observedAt, which is true only for the run that produced the secret, since rotate writes the snapshot and the secret in one call with one timestamp while a plain sync refreshes the snapshot alone.
Data references in step inputs are safe-navigated. A step's inputs are evaluated even when its when is false — when gates execution, not evaluation — so data.latest(model,'current').attributes.token killed the run with "No such key: attributes" before any rotation had ever happened. The placeholder that replaces it contains spaces, which GitLab's masking rules forbid, so a masked write of it is refused with a 400 rather than landing a junk value in a live CI variable.
Guarded by tests over the workflow file itself, because no model test could see any of this: trigger is absent, propagate's when mentions observedAt and does NOT mention daysRemaining, and both data inputs are safe-navigated.
Known issue, recorded rather than carried silently: the valid-target preflight reads the raw globalArguments rather than the zod-parsed ones, so an instance relying on the documented baseUrl default fails its own check with baseUrl "undefined". Write baseUrl: https://gitlab.com explicitly. The fix belongs in the check and would have made this release's no-op upgrade entry untrue, so it is deferred.
Upgrading is a no-op for model instances. If you VENDORED a copy of the workflow, no upgrade can reach it — take this one.
Rotation now completes the handover, addresses the right token, and the workflow actually loads.
update-ci-variable — a new method that writes the rotated value into a GitLab CI/CD variable. Without it a scheduled rotation was a timed outage: GitLab discloses a token value exactly once and revokes the outgoing one immediately, so nothing downstream ever received the replacement. It updates and deliberately will not create, because upserting a mistyped key produces a variable nothing reads while the real consumer keeps presenting a revoked value — a silent outage reported as a green run. masked and protected are re-asserted on every write, since GitLab does not carry them forward and a silently unmasked secret prints in the next job log.
tokenName — identify the managed token by name rather than by id. Rotation mints a new id every time, so a configured tokenId names the revoked predecessor from the second run onward: the schedule authenticates fine and then operates on a dead token forever. A rotation schedule configured with tokenId worked exactly once. Names survive rotation. Resolution is exact-match over active tokens only (GitLab's search is a substring match), duplicates are refused rather than guessed between, and setting both identifiers is an error. tokenId remains correct for pinning one specific generation.
Stable aliases — current on the secret spec and managed on the token spec. Every instance was previously keyed by a token id that does not exist until the rotation creates it, so nothing authored beforehand could name one, and findBySpec returns every generation with no CEL construct for "the newest". Read data.latest(model, 'current').attributes.token. managed also fixes a latent workflow bug: the threshold predicate used findBySpec, which still returns the generation just rotated away sitting at zero days remaining, so it stayed true permanently once any token neared expiry and would have rotated on every run.
The rotation workflow now loads. It requires a top-level UUID id and had none, so swamp dropped it with a single warning: swamp workflow list omitted it and swamp workflow run answered "Workflow not found" for a file in the repo. The manifest records that a workflow file exists, not that it parses, so 2026.08.19.2 packaged, published and scored 100% around a workflow that could never be invoked. Fixed, and guarded by a test over the file itself — no preflight check can catch this, since checks run against a model instance and the broken artifact was a workflow.
The workflow is now three jobs: check, then rotate, then propagate. Both rotation gates are unchanged — rotate defaults to false, and the threshold must also hold — so the scheduled run stays read-only. New inputs ciVariableProject and ciVariableKey; leaving either empty skips propagation.
No upgrade action required. tokenName is optional with no default and no persisted resource shape changed. Existing instances keep working on tokenId, including its rotation weakness; moving to tokenName changes which token a rotation acts on and is therefore an operator's edit rather than something an upgrade should do unasked.
Note for GitLab.com Free namespaces: project and group access tokens cannot be created at all — both endpoints refuse even a group Owner, naming a permission no role can grant. Fine-grained personal tokens are the least-privilege option available there.
Modified 1 models. Added 1, removed 1 workflows
Added 1 workflows
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License declared1/1earned
- Verified public repository2/2earned