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
Install the
srcCLI and log in. Install and set up the CLI shows how. Then list the CI commands:src ci helpconvert,planandrunwork without a login. The commands that read from the forge or change it use yoursrclogin and the repository of your git remote.Convert the workflows. Run
src ci convertin 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 convertdoes not replace a file that exists. To replace it, add--force.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 ine2e.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 offTo list all of them, run
grep -rn "TODO(convert):" .source/workflows/.src ci planaccepts 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.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 fileskills/tanstack-source-ci/SKILL.mdin 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 promptconvert_github_actions. To move one workflow, give its path as thefileargument.
The agent finishes the TODOs, runs
src ci plan, and runs each job until it passes.The TODO of the
TanStack/configsetup action asks for asrc:import. After you add the import, runsrc 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 syncdownloads the pinned modules. See Share steps.Run each job until it passes. Run
src ci planfirst. Then run each job withsrc ci run.src ci plan src ci run pr test src ci run e2e e2e --matrix shard=1 --dockersrc ci planshows each workflow and its jobs, or the errors. For tanstack/ai, it ends with7 workflows, 11 jobs.src ci run <workflow> <job>runs one job on your machine. Run it again until it exits with 0.--matrix shard=1runs one cell of a matrix job.--with-needsruns the jobs that the job needs first, and gives it their outputs.--dockerruns the job in its CI image (nodeorbrowser), 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
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, deleteoptional: true. - The
releasejob publishes withNPM_TOKEN. When you finish itsid-token: writeTODO, declareNPM_TOKENon the job. - The converted files read
secrets.GITHUB_TOKENas 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 invars.src variable set REGION --body eu-westCI variables has the rules.
- A value of
Push to
main. Commit.source/onmain. 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 mainPush 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,e2eandrelease. Runsrc ci runs --branch mainuntil each run showssuccess. If a run fails,src ci view <run> --log-failedshows the logs of its failed jobs.The gh commands work too:
src run list --branch main,src run watch <run>andsrc run view <run> --log-failed.Add branch rules. A branch rule lets
maintake 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-resultSet 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 fromalpha,betaorrc, add a rule for each of them. Without a rule, a push there is trusted, and thereleasejob gets noNPM_TOKEN.
Branch rules has every option.
- A required check is
GitHub Actions to TanStack Source
| GitHub Actions | TanStack Source |
|---|---|
actions/checkout | Built in. Each job checks out the commit of the run. fetch-depth is checkout: { depth } on the job. |
actions/cache | cache({ key, paths, restoreKeys }) |
actions/upload-artifact | artifact({ name, paths, retentionDays }) |
actions/setup-node, oven-sh/setup-bun, pnpm/action-setup | setup.node(), setup.bun(), setup.pnpm() |
TanStack/config/.github/setup | A src: import, like src:tanstack/config@<tag>/ci/setup.ts |
nrwl/nx-set-shas | Built in: ctx.base and ctx.head |
autofix-ci/action | fixup({ message }) |
gh pr, gh issue, gh label | forge(ctx) in a do step |
changesets/action | A port by hand with forge(ctx). The forge creates no releases. |
on: pull_request, on: push | pullRequest(), push() |
on: workflow_dispatch | manual({ inputs }) |
on: schedule | schedule(cron) |
on: workflow_run | after(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 job | if: (ctx) => ... on the job. always() and !cancelled() become when: 'always', and failure() becomes when: 'on-failure'. |
github.repository, github.repository_owner | Read from ctx.env.TSS_REPO. A comparison ignores case, as on GitHub. |
concurrency with cancel-in-progress: true | cancelOlderRuns, 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 machine | The same commands in src. See gh commands for CI. |
permissions | permissions, 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/actionport 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 anNPM_TOKENsecret. - GraphQL scripts.
maintainer-sweepandmaintainer-scorecardcall the GitHub GraphQL API, so they stay on GitHub. - AI review.
ai-reviewandai-review-signalstay 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/checkoutis built in.fetch-depth: 0ischeckout: { depth: 0 }.- The job runs Playwright, so it gets the
browserimage and themediumsize. ${{ matrix.shard }}goes to the script in the variableSHARD, never as script text.if: ${{ !cancelled() }}on the gate job iswhen: '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.afterJobruns each upload after the last step of the job. Withalways: 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.