Skip to main content

Actions and CI/CD

fremforge runs Forgejo Actions, a GitHub Actions-compatible CI/CD system. Workflow files use GitHub Actions YAML syntax and approximately 95% of GitHub Marketplace actions work without modification.

Workflow file location

Place workflow files in .forgejo/workflows/<name>.yaml inside your repository. Forgejo also reads .github/workflows/ for compatibility with repositories migrated from GitHub, but .forgejo/workflows/ is the canonical location on fremforge.

Trigger events

EventTrigger
pushAny push to matched branches or tags
pull_requestPR opened, synchronised, or reopened
pull_request_targetPR against target repo (for forks) — see security note below
scheduleCron schedule (UTC)
workflow_dispatchManual trigger from UI or API
workflow_callReusable workflow called by another workflow
releaseRelease published, created, or edited
issuesIssue opened, edited, closed, or labeled
issue_commentComment on issue or PR
createBranch or tag created
deleteBranch or tag deleted
registry_packagePackage published or updated

Context variables

fremforge uses github.* context names for compatibility with the GitHub Actions ecosystem. These refer to the fremforge/Forgejo instance, not GitHub.

VariableValue
github.actorUsername of the user who triggered the run
github.repository<org>/<repo>
github.refFull git ref, e.g. refs/heads/main
github.shaCommit SHA
github.event_nameEvent that triggered the workflow
github.run_idUnique run identifier
github.server_urlhttps://frem.sh
github.api_urlhttps://frem.sh/api/v1

Built-in secrets

SecretDescription
secrets.FORGEJO_TOKENAuto-generated per-job token; scoped to the repo; read-only by default, except that it can publish the repo’s packages
secrets.GITHUB_TOKENAlias for FORGEJO_TOKEN (compatibility)

Built-in tokens are short-lived and scoped to the current workflow run. They cannot be used outside the job that received them.

Permissions block

The default permission set is contents: read. All other permissions default to none unless explicitly declared.

permissions:
  contents: read       # git checkout
  id-token: write      # OIDC token federation
  issues: write        # create / update issues
  pull-requests: write # comment on PRs

Declare permissions at the workflow level (applies to all jobs) or at the individual job level. Job-level declarations override workflow-level ones.

packages: write is not in this list because it is not needed. The built-in per-job token already reads every package of the repository’s owner and writes packages linked to its own repository (fork pull request runs read only). See Publishing from Forgejo Actions.

Minimal workflow example

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: fremforge
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm ci && npm test

Reusable workflows

Extract common logic into a reusable workflow called by other workflows with workflow_call:

# .forgejo/workflows/reusable.yaml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string

jobs:
  deploy:
    runs-on: fremforge
    environment: ${{ inputs.environment }}
    steps:
      - run: echo "Deploying to ${{ inputs.environment }}"

Call it from another workflow:

jobs:
  deploy-staging:
    uses: ./.forgejo/workflows/reusable.yaml@main
    with:
      environment: staging

Caching

Use actions/cache to persist directories between runs:

- uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

Cache hits speed up jobs significantly for package managers, build tools, and compiled outputs. The built-in caching of actions/setup-node, setup-python, setup-go and setup-java (cache: npm and so on) uses the same service.

How the cache behaves on fremforge:

  • Scoped to the repository. A cache saved in one repository is never visible to another, in the same organization or any other. Within a repository, branches share one cache.
  • Pull requests are isolated. A pull_request run can restore the repository’s caches, but what it saves is visible only to later runs of the same pull request, so a pull request (including one from a fork) cannot change what other branches restore.
  • Limits. Up to 10 GiB per entry and 10 GiB per repository; when a repository goes over, its least recently used entries are removed. Entries unused for 7 days, or older than 30 days, are removed.
  • Never fails a job. If the cache is unavailable, actions/cache logs a warning and the job continues without it.

The cache is stored on fremforge’s own infrastructure in the EU.

Environments and deployment gates

Environments add approval gates, required reviewers, and environment-scoped secrets to deploy jobs.

Create environments under Repo Settings → Environments. Configure required reviewers to require manual approval before the job runs.

Reference an environment in a job:

jobs:
  deploy-prod:
    runs-on: fremforge
    environment: production
    steps:
      - run: ./deploy.sh

If production has required reviewers configured, the job pauses for approval before executing. Secrets set on the production environment are injected only into this job.

Compatibility notes

  • ~95% of GitHub Marketplace actions work without modification.
  • secrets.GITHUB_TOKEN is an alias for secrets.FORGEJO_TOKEN. Actions using it work as-is.
  • Actions that call the GitHub API directly (hardcoded api.github.com) will fail. The fremforge API URL is https://frem.sh/api/v1.
  • actions/upload-artifact and actions/download-artifact work.
  • GitHub Packages (ghcr.io, pkg.github.com) are not available. Use the fremforge package registry instead.
  • Actions that require GitHub-specific features (GitHub Copilot, GitHub Codespaces, GitHub Packages) have no fremforge equivalent.
  • The workflow commands ::set-output, ::set-env and ::add-path do nothing. Write to $GITHUB_OUTPUT, $GITHUB_ENV and $GITHUB_PATH instead; see Outputs, environment variables and PATH.
  • An expression that cannot be evaluated fails its step or job, and so does an invalid strategy.matrix, such as an exclude that names a value the matrix does not have.
  • Job summaries are not supported. A step that writes to $GITHUB_STEP_SUMMARY still succeeds, but the summary is discarded and the step log shows the warning This Forgejo instance does not support job summaries.

See Marketplace compatibility for the full top-100 action compatibility matrix.

Outputs, environment variables and PATH

Since 21 October 2026 the hosted runners run forgejo-runner 13, which removed three workflow commands: ::set-output, ::set-env and ::add-path. A step that prints one of them gets a warning in its log and otherwise carries on, but the output, environment variable or PATH entry is not set. The job may still go green, and a later step or job then reads an empty value.

This matters most for workflows ported from GitHub, where ::set-output is deprecated but still accepted. Setting ACTIONS_ALLOW_UNSECURE_COMMANDS=true does not bring the commands back.

Write to the files the runner names instead. Each one is available as GITHUB_* and as FORGEJO_*, pointing at the same file, so a workflow that also runs on GitHub keeps working with the GITHUB_* names.

Removed commandWrite to instead
echo "::set-output name=NAME::value"echo "NAME=value" >> "$GITHUB_OUTPUT" (or $FORGEJO_OUTPUT)
echo "::set-env name=NAME::value"echo "NAME=value" >> "$GITHUB_ENV" (or $FORGEJO_ENV)
echo "::add-path::/some/dir"echo "/some/dir" >> "$GITHUB_PATH" (or $FORGEJO_PATH)

Before, which now sets nothing:

      - id: version
        run: echo "::set-output name=tag::$(git describe --tags)"
      - run: echo "Releasing ${{ steps.version.outputs.tag }}"   # empty

After:

      - id: version
        run: echo "tag=$(git describe --tags)" >> "$GITHUB_OUTPUT"
      - run: echo "Releasing ${{ steps.version.outputs.tag }}"

To find the commands in a repository: git grep -nE '::(set-output|set-env|add-path)'. A Marketplace action that still prints them loses its outputs the same way; update it to a release that writes to the files.

Fork-PR secret reach — pull_request_target policy

pull_request_target runs workflows in the target repo’s context when a PR comes from a fork — meaning the workflow has access to the target repo’s secrets even though the workflow file may have been modified by the fork author. This is the exact attack shape that produced the GitHub Actions Marketplace 2023 incident class.

fremforge policy: workflows that use pull_request_target MUST NOT execute fork-supplied code with secrets present. Two acceptable patterns:

  1. pull_request instead of pull_request_target when the workflow needs to run fork code. The fork-PR’s workflow runs in the fork’s context — no target-repo secrets reachable. This is the default and the safe shape.
  2. pull_request_target ONLY for read-only labeller/triage/comment workflows that explicitly check out github.sha (the merge-base, not the fork head) and don’t run any fork-supplied install/build/test step.

Workflows that mix pull_request_target with actions/checkout@v4 against github.event.pull_request.head.sha are a SEV-1 secret-exfiltration vector and will be flagged by the pre-merge review. A pre-receive Forgejo hook that statically rejects the dangerous combination is on the future-hardening roadmap; until it ships, code-review is the active gate.

For full background: Forgejo Actions security model is the same as GitHub’s. The pull_request_target semantics are inherited unchanged.

Image-source policy

uses: statements come in two shapes — both are accepted on fremforge but the egress posture differs:

  • uses: <owner>/<repo>@<sha> — resolved against the EU action mirror at frem.sh/<owner>/<repo> — each upstream publisher has its own mirror org (actions, docker, aws-actions, azure, google-github-actions, peter-evans, softprops, sonarsource, step-security, dorny, pnpm), so uses: actions/checkout@<sha> resolves to frem.sh/actions/checkout. Mirrors sync from upstream every 8h. This is the recommended shape.
  • uses: docker://<registry>/<image>:<tag> — Forgejo Actions resolves this by pulling the image directly from the registry URL the workflow author specified. There’s no automatic rewrite to a fremforge mirror, and no built-in allowlist on the registry portion today. Pin to a digest (docker://<registry>/<image>@sha256:…) rather than a mutable tag. A pre-receive Forgejo hook or runner-side allowlist for docker:// is on the future-hardening roadmap — until it ships, document the policy in your repo’s CONTRIBUTING.md and treat unpinned docker:// references as a code-review issue.

The workflow-run API

Four routes exist and work. Only the first two are documented anywhere else, which is why the other two are usually reported as missing:

WhatRoute
List runsGET /api/v1/repos/<owner>/<repo>/actions/runs
Fetch logsGET /api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/logs
Cancel a runPOST /api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/cancel
Re-run a runPOST /api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/rerun
# stop a run that is queued or in progress
curl -X POST -H "Authorization: token <forgejo-pat>" \
  https://frem.sh/api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/cancel
# -> 204, and the run's status becomes `cancelled`

Why cancel looks like it does not exist

Every signal points away from it, so “I could not find it” is the expected outcome rather than a failure to look:

  • DELETE returns 405. The route is POST only. DELETE .../cancel and both verbs on .../runs/<id> (without /cancel) all return 405.
  • /actions/tasks/<id>/cancel returns 404. Tasks are not runs.
  • Forgejo’s own OpenAPI spec is not served here. /swagger.v1.json and every variant of it return 404 on frem.sh, so there is nothing to look the route up in.
  • The fremforge api spec does not describe it, and cannot. It documents this platform’s endpoints, not Forgejo’s — cancel appears in it only under /orgs/{org}/billing/cancel, which is subscriptions. Searching it for a way to cancel a run finds the wrong cancel.
  • The fremforge api answers 501. That is the catch-all for /api/v1/* paths matching no endpoint, and its message points at the fremforge OpenAPI spec — i.e. back to the spec that does not contain this route.
  • ffp_* tokens cannot do it at all. The org-scoped proxy bridges the logs route only (see below); cancel and re-run need a Forgejo PAT or session cookie.

If you are cancelling runs by hand more than once

Do not script it — set a concurrency group instead, so Forgejo cancels the superseded run itself:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Every push to a branch then supersedes its own previous run. Safe for required checks: statuses are per-commit, so cancelling the run for an older commit cannot leave the current head’s checks hanging.

A cancelled run leaves its checks pending, not failed. So cancelling a run on the commit you are trying to merge will hang an armed auto-merge forever — it waits on a check that will never report again. Push, or POST .../rerun.

Enabling and disabling workflows

The Disable workflow / Enable workflow buttons on a repository’s Actions tab have API equivalents. The paths, operation names and JSON are Gitea’s, so a client written for Gitea’s API works unchanged.

WhatRouteWho
List workflows and their stateGET /api/v1/repos/<owner>/<repo>/actions/workflowsanyone who can read the repository’s Actions
Get one workflowGET /api/v1/repos/<owner>/<repo>/actions/workflows/<workflow_id>anyone who can read the repository’s Actions
Disable a workflowPUT /api/v1/repos/<owner>/<repo>/actions/workflows/<workflow_id>/disablerepository admin, with a token
Enable a workflowPUT /api/v1/repos/<owner>/<repo>/actions/workflows/<workflow_id>/enablerepository admin, with a token

<workflow_id> is the workflow’s file name, e.g. ci.yaml, as it appears in .forgejo/workflows/ (or .gitea/workflows/, .github/workflows/) on the default branch.

# switch a workflow off, then back on
curl -X PUT -H "Authorization: token <forgejo-pat>" \
  https://frem.sh/api/v1/repos/<owner>/<repo>/actions/workflows/ci.yaml/disable
# -> 204
curl -X PUT -H "Authorization: token <forgejo-pat>" \
  https://frem.sh/api/v1/repos/<owner>/<repo>/actions/workflows/ci.yaml/enable
# -> 204

# which workflows are switched off?
curl -H "Authorization: token <forgejo-pat>" \
  https://frem.sh/api/v1/repos/<owner>/<repo>/actions/workflows
{
  "workflows": [
    {
      "id": "ci.yaml",
      "name": "CI",
      "path": ".forgejo/workflows/ci.yaml",
      "state": "disabled_manually",
      "created_at": "2026-10-09T08:00:00Z",
      "updated_at": "2026-10-09T08:00:00Z",
      "url": "https://frem.sh/api/v1/repos/<owner>/<repo>/actions/workflows/ci.yaml",
      "html_url": "https://frem.sh/<owner>/<repo>/src/branch/main/.forgejo/workflows/ci.yaml",
      "badge_url": "https://frem.sh/<owner>/<repo>/badges/workflows/ci.yaml/badge.svg?branch=main"
    }
  ],
  "total_count": 1
}
  • state is active or disabled_manually. It is the same setting the buttons change, so the UI and the API always agree.
  • name is the workflow’s name:, or the file name when it has none. created_at and updated_at are both the time of the default branch’s latest commit, as on Gitea, not the file’s own history.
  • Disabling and enabling need repository admin, the same as the buttons. A user with write access gets 403. This is stricter than Gitea, which lets writers do it.
  • 404 for a file that is not a workflow on the default branch. A typo, a file on another branch, or a file that is not .yml/.yaml in the workflow directory is refused rather than recorded.
  • An archived repository answers 423 to enable and disable. It can still be read.
  • Both calls are idempotent. Disabling a disabled workflow returns 204 and changes nothing.
  • What “disabled” stops: runs from events (push, pull request and the rest), scheduled runs, and re-runs of its existing runs (POST .../rerun answers 422). A manual dispatch (POST .../actions/workflows/<workflow_id>/dispatches) still runs, exactly as Run workflow does in the UI.
  • ffp_* tokens cannot call these routes. They need a Forgejo PAT (scope write:repository for enable/disable) or a session, like cancel and re-run.

Fetching workflow logs programmatically

The Forgejo web UI shows action run logs interactively. For programmatic access (failure notifiers, debug scripts, compliance exports, AI agents triaging failures) there are two endpoints depending on which token type you hold:

With a Forgejo PAT or session cookie

curl -H "Authorization: token <forgejo-pat>" \
  https://frem.sh/api/v1/repos/<owner>/<repo>/actions/runs/<run_id>/logs

Returns text/plain — every job of the run concatenated, with === job: <name> (status=<status>, attempt=<n>) === headers between jobs. The <run_id> is the global id Forgejo returns from GET /api/v1/repos/<owner>/<repo>/actions/runs (the same id the web UI shows in /<owner>/<repo>/actions/runs/<n>). RBAC is Forgejo’s standard repo-read check; bot users with the right org membership work the same as human users.

With a fremforge api PAT (ffp_*)

If your token starts with ffp_ — including PATs minted in the admin UI, tokens received from POST /_app/api/v1/auth/token-exchange (OIDC exchange), and tokens issued by POST /_app/api/v1/oauth/token (client_credentials) — Forgejo doesn’t recognise it. Use the fremforge api proxy instead:

curl -H "Authorization: Bearer <ffp_…>" \
  https://frem.sh/_app/api/v1/orgs/<org>/repos/<repo>/actions/runs/<run_id>/logs

Same response shape (text/plain, concatenated jobs, headers between them). Required scope: runners:read. The proxy verifies the token’s tenant binding (<org> in the URL must match the token’s tenant) before forwarding to Forgejo with a system credential — the system credential never leaves the platform, so a leaked customer PAT can only read its own tenant’s logs.

Both endpoints emit the same body, so a caller switching auth shapes only needs to change the URL and the Authorization header.

Workaround note. The /api/v1/orgs/<org>/repos/<repo>/... proxy is a temporary bridge until Forgejo upstream supports api-domain (ffp_*) tokens on its native /api/v1/repos/<owner>/<repo>/... endpoint. When that ships, the proxy is retired and ffp_* tokens will hit the same path as Forgejo PATs. The response shape stays identical; only the URL shape changes.

When logs are unavailable

  • 404 — run not found, or not owned by the URL’s repo.
  • 410 — logs have been purged by the cleanup job (action runs older than the retention window).
  • 502 — Forgejo origin unreachable. Retry; if it persists, check status.frem.sh.

Cross-references