Skip to content
Source

Move from GitHub Actions

Your CI is in .github/workflows/*.yml, and it runs only on GitHub. You want the same checks to run on TanStack Source.

src ci convert converts each workflow into a TypeScript workflow in .source/workflows/. It converts by rules. For each part that no rule covers, it writes a // TODO(convert) comment. You finish the TODOs, run each job on your machine until it passes, and push.

You need admin access to the repository on TanStack Source, because secrets and branch rules are admin settings. The examples use tanstack/ai. It has 12 workflows: 7 move, 4 stay on GitHub, and zizmor is dropped.

Convert and test on your machine

  1. Install the src CLI and log in. Install and set up the CLI shows how. Then list the CI commands:

    src ci help

    convert, plan and run work without a login. The commands that read from the forge or change it use your src login and the repository of your git remote.

  2. Convert the workflows. Run src ci convert in the root of the repository. Without paths, it converts every file in .github/workflows/. To keep a workflow on GitHub, leave its file out (What does not move today). For tanstack/ai:

    src ci convert .github/workflows/pr.yml .github/workflows/e2e.yml .github/workflows/bun-test.yml \
      .github/workflows/autofix.yml .github/workflows/release.yml .github/workflows/sync-models.yml \
      .github/workflows/coverage.yml
    .github/workflows/pr.yml -> .source/workflows/pr.ts: 9 TODOs
    .github/workflows/e2e.yml -> .source/workflows/e2e.ts: 10 TODOs
    .github/workflows/bun-test.yml -> .source/workflows/bun-test.ts: 2 TODOs
    .github/workflows/autofix.yml -> .source/workflows/autofix.ts: 2 TODOs
    .github/workflows/release.yml -> .source/workflows/release.ts: 13 TODOs
    .github/workflows/sync-models.yml -> .source/workflows/sync-models.ts: 12 TODOs
    .github/workflows/coverage.yml -> .source/workflows/coverage.ts: 2 TODOs
    Converted 7 files with 50 TODOs in total.
    Finish each TODO(convert), then check each job with src ci run <workflow> <job>.

    src ci convert does not replace a file that exists. To replace it, add --force.

  3. Read the TODO list. Each // TODO(convert): <reason> comment is at the workflow, job or step that it is about. The reason says what has no counterpart, and often what to do. These are 3 of the 10 TODOs in e2e.ts:

    // TODO(convert): the input key of actions/cache reads ${{ hashFiles('testing/e2e/package.json') }}, but built-in steps take fixed values
    // TODO(convert): the condition steps.playwright-cache.outputs.cache-hit != 'true' has no counterpart; the step is off until it is converted
    // TODO(convert): this step ran only after a failed step (failure()), but a failed step stops the job here; the step is off

    To list all of them, run grep -rn "TODO(convert):" .source/workflows/.

    src ci plan accepts the files with their TODOs. But a step or a job that waits for a TODO is off (if: () => false) or left out. Finish every TODO before you push.

  4. Finish the TODOs. Do the work by hand, or give it to a coding agent:

    • By hand: do what each reason says. Then delete the comment. The example finishes the 6 TODOs of the e2e upload steps. The reference has every step and option.
    • With the agent skill: give your agent the skill tanstack-source-ci. It is the file skills/tanstack-source-ci/SKILL.md in the npm package @tanstack-source/ci, and it has the fix for each kind of TODO.
    • With MCP: connect your agent to the MCP server of the forge (/mcp). Then start the prompt convert_github_actions. To move one workflow, give its path as the file argument.

    The agent finishes the TODOs, runs src ci plan, and runs each job until it passes.

    The TODO of the TanStack/config setup action asks for a src: import. After you add the import, run src ci update. It pins the module in .source/src.lock, and downloads it for your editor and for local runs. On a new checkout, src ci sync downloads the pinned modules. See Share steps.

  5. Run each job until it passes. Run src ci plan first. Then run each job with src ci run.

    src ci plan
    src ci run pr test
    src ci run e2e e2e --matrix shard=1 --docker
    • src ci plan shows each workflow and its jobs, or the errors. For tanstack/ai, it ends with 7 workflows, 11 jobs.
    • src ci run <workflow> <job> runs one job on your machine. Run it again until it exits with 0.
    • --matrix shard=1 runs one cell of a matrix job.
    • --with-needs runs the jobs that the job needs first, and gives it their outputs.
    • --docker runs the job in its CI image (node or browser), as the forge does.

    A local run has the trust level local, and it reads secrets from the environment and from .env. See Run jobs on your machine.

Go live

  1. Add the CI secrets and variables. A secret has one value for each trust level, and the hosts that the value can go to. tanstack/ai needs two secrets:

    src ci secret set NX_CLOUD_ACCESS_TOKEN --untrusted - --host cloud.nx.app < nx-read-only.txt
    src ci secret set NX_CLOUD_ACCESS_TOKEN --trusted - --protected - < nx-read-write.txt
    src ci secret set NPM_TOKEN --protected - --host registry.npmjs.org < npm-publish.txt
    • A value of - comes from stdin, so it stays out of your shell history.
    • Forks get the read-only Nx Cloud token. Only protected runs get NPM_TOKEN.
    • The converter declares each secret as { optional: true }. If a job cannot work without a secret, delete optional: true.
    • The release job publishes with NPM_TOKEN. When you finish its id-token: write TODO, declare NPM_TOKEN on the job.
    • The converted files read secrets.GITHUB_TOKEN as the run token, TSS_TOKEN. It needs no secret.

    Trust levels and secrets explains each level.

    If a workflow reads vars.<NAME>, set each variable too. The converter declares it on the job in vars.

    src variable set REGION --body eu-west

    CI variables has the rules.

  2. Push to main. Commit .source/ on main. Then push it to the repository on TanStack Source.

    git add .source/
    git commit -m "ci: run CI on TanStack Source"
    git push
    src ci runs --branch main

    Push to the default branch before you open pull requests. A pull request run takes the secrets, permissions and egress of its jobs from the base branch. So a job that is new in a pull request runs with none of them.

    On tanstack/ai, the push starts autofix, coverage, e2e and release. Run src ci runs --branch main until each run shows success. If a run fails, src ci view <run> --log-failed shows the logs of its failed jobs.

    The gh commands work too: src run list --branch main, src run watch <run> and src run view <run> --log-failed.

  3. Add branch rules. A branch rule lets main take changes only through pull requests. It also blocks a merge until the required checks pass.

    src ci rule set main --require-pull --check pr/test --check e2e/e2e-result
    Set the branch rule main: requires a pull request; required checks pr/test, e2e/e2e-result.
    • A required check is <workflow>/<job>. The checks come from the plan of the base branch, so add the rule after step 7.
    • A push to a branch with a rule is protected, like a push to main. If you publish from alpha, beta or rc, add a rule for each of them. Without a rule, a push there is trusted, and the release job gets no NPM_TOKEN.

    Branch rules has every option.

GitHub Actions to TanStack Source

GitHub ActionsTanStack Source
actions/checkoutBuilt in. Each job checks out the commit of the run. fetch-depth is checkout: { depth } on the job.
actions/cachecache({ key, paths, restoreKeys })
actions/upload-artifactartifact({ name, paths, retentionDays })
actions/setup-node, oven-sh/setup-bun, pnpm/action-setupsetup.node(), setup.bun(), setup.pnpm()
TanStack/config/.github/setupA src: import, like src:tanstack/config@<tag>/ci/setup.ts
nrwl/nx-set-shasBuilt in: ctx.base and ctx.head
autofix-ci/actionfixup({ message })
gh pr, gh issue, gh labelforge(ctx) in a do step
changesets/actionA port by hand with forge(ctx). The forge creates no releases.
on: pull_request, on: pushpullRequest(), push()
on: workflow_dispatchmanual({ inputs })
on: scheduleschedule(cron)
on: workflow_runafter(workflow, { conclusion }). When the conditions check github.event.workflow_run.conclusion == 'success', the conclusion is 'success'. Without such a check, it is 'any'.
if: on a jobif: (ctx) => ... on the job. always() and !cancelled() become when: 'always', and failure() becomes when: 'on-failure'.
github.repository, github.repository_ownerRead from ctx.env.TSS_REPO. A comparison ignores case, as on GitHub.
concurrency with cancel-in-progress: truecancelOlderRuns, which is on by default
${{ secrets.X }}A secret that the job declares, with a value for each trust level and the hosts that it can go to
${{ vars.X }}A CI variable that the job declares in vars. The converter adds the declaration. The steps read the value from the environment, as $X or ctx.env.X.
gh run, gh workflow, gh secret, gh variable, gh cache on your machineThe same commands in src. See gh commands for CI.
permissionspermissions, typed, with a ceiling from the trust level of the run

ctx.base is the commit to compare with: the base of a pull request, or, for a push, the head of the last run of the workflow that passed on that branch. ctx.head is the commit that the run tests.

What does not move today

  • Releases. The forge has no releases. The changesets/action port publishes to npm and opens the version pull request, but it creates no release.
  • npm provenance. id-token: write (OIDC) has no counterpart. Publish with an NPM_TOKEN secret.
  • GraphQL scripts. maintainer-sweep and maintainer-scorecard call the GitHub GraphQL API, so they stay on GitHub.
  • AI review. ai-review and ai-review-signal stay on GitHub. On the forge, the software factory runs agents on each pull request.
  • zizmor. It is dropped. The forge checks each workflow file when it plans it.

Keep the workflows that stay on GitHub in .github/workflows/. Do not give their files to src ci convert.

Example: the e2e workflow

tanstack/ai runs its end-to-end tests in 4 shards. A gate job reports one result for all of them. This is part of .github/workflows/e2e.yml:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
      # ...
      - name: Run E2E Tests
        env:
          SHARD: ${{ matrix.shard }}
        run: pnpm --filter @tanstack/ai-e2e test:e2e --shard="$SHARD/4"

      - name: Upload Traces (on failure)
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        if: failure()
        with:
          name: e2e-traces-${{ matrix.shard }}
          path: testing/e2e/test-results/**/*.zip
          retention-days: 14

  e2e-result:
    name: E2E Tests
    needs: e2e
    if: ${{ !cancelled() }}
    runs-on: ubuntu-latest
    steps:
      - name: Check shard results
        env:
          RESULT: ${{ needs.e2e.result }}
        run: test "$RESULT" = success

This is the same part of .source/workflows/e2e.ts, as src ci convert writes it:

export default defineWorkflow('e2e', {
  on: [pullRequest(), push({ branches: ['main', 'alpha', 'beta', 'rc'] })],
})
  .job('e2e', {
    // TODO(convert): check the egress hosts: they are guessed from the job's commands, and its scripts can reach others
    image: 'browser',
    size: 'medium',
    timeoutMinutes: 30,
    checkout: { depth: 0 },
    matrix: { shard: [1, 2, 3, 4] },
    secrets: { NX_CLOUD_ACCESS_TOKEN: { optional: true } },
    egress: ['registry.npmjs.org', 'cloud.nx.app', 'cdn.playwright.dev', 'playwright.download.prss.microsoft.com'],
    steps: [
      // ...
      {
        name: 'Run E2E Tests',
        do: (ctx) => ctx.sh`SHARD=${ctx.matrix.shard} bash -ec ${'pnpm --filter @tanstack/ai-e2e test:e2e --shard="$SHARD/4"'}`,
      },
      // ...
      // TODO(convert): the input name of actions/upload-artifact reads ${{ matrix.shard }}, but built-in steps take fixed values
      // TODO(convert): this step ran only after a failed step (failure()), but a failed step stops the job here; the step is off
      { ...artifact({ name: 'e2e-traces-${{ matrix.shard }}', paths: ['testing/e2e/test-results/**/*.zip'], retentionDays: 14 }), if: () => false },
    ],
  })
  .job('e2e-result', {
    needs: ['e2e'],
    when: 'always',
    secrets: { NX_CLOUD_ACCESS_TOKEN: { optional: true } },
    steps: [
      {
        name: 'Check shard results',
        do: (ctx) => ctx.sh`RESULT=${ctx.needs.e2e.result} bash -ec ${'test "$RESULT" = success'}`,
      },
    ],
  })

What the converter did:

  • actions/checkout is built in. fetch-depth: 0 is checkout: { depth: 0 }.
  • The job runs Playwright, so it gets the browser image and the medium size.
  • ${{ matrix.shard }} goes to the script in the variable SHARD, never as script text.
  • if: ${{ !cancelled() }} on the gate job is when: 'always'.
  • A failed step stops the job here. So each upload step has a TODO, and the traces upload is off.

To finish the 6 TODOs of the upload steps, delete the three artifact() steps and their TODOs. Then put this step first in the e2e job:

{
  name: 'Upload results after the job',
  do: (ctx) => {
    const upload = (name: string, paths: string[]) =>
      ctx.afterJob((after) => artifact({ name: `${name}-${ctx.matrix.shard}`, paths, retentionDays: 14 }).do(after), { always: true })
    upload('e2e-videos', ['testing/e2e/test-results/**/*.webm'])
    upload('e2e-report', ['testing/e2e/playwright-report/'])
    upload('e2e-traces', ['testing/e2e/test-results/**/*.zip'])
  },
},
  • ctx.afterJob runs each upload after the last step of the job. With always: true, it runs also when a step failed.
  • The step is first, so it registers the uploads before any step can fail.
  • When no file matches, an upload sends nothing, and the job does not fail.

src ci plan shows the 4 shards and the gate:

e2e (.source/workflows/e2e.ts)
  on: pull request, push (branches: main, alpha, beta, rc)
  jobs:
    e2e: 4 matrix cells (shard: 1, 2, 3, 4)
    e2e-result: needs e2e; runs always

The result

src ci runs --branch main
ID                                    STATUS   WORKFLOW  EVENT  REF   COMMIT
0b7d9c41-6e2a-4f3b-8d15-a9c2e7f04b6d  success  release   push   main  4e5f6a7
c3a18f72-9d4e-4b06-a5e1-7f2b8c9d0e13  success  e2e       push   main  4e5f6a7
8e2f4a6b-1c3d-4e5f-9a7b-0c1d2e3f4a5b  success  coverage  push   main  4e5f6a7
5d6e7f80-a1b2-4c3d-8e4f-5a6b7c8d9e0f  success  autofix   push   main  4e5f6a7

The converted workflows run on TanStack Source, and they pass. From now on, a pull request into main merges only when pr/test and e2e/e2e-result passed on its head commit.