Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5bf553
feat(ci): group a run's steps into trace spans and name CI's own steps
Oct 9, 2026
ea84b11
refactor(ci): span catalog, staged machine start, span kinds folded in
Oct 10, 2026
5c9fd49
refactor(ci): keep the staged machine start in machine.ts
Oct 10, 2026
7f9092b
refactor(ci): require inngest 4.23.1 and drop the older-SDK paths
Oct 10, 2026
98f6ad1
refactor(ci): share step names, sandbox admin, snapshot names and cac…
Oct 13, 2026
ee37919
feat(ci): check out another repository or ref with checkout({ repo, r…
Oct 13, 2026
d42ab5b
Update ci-trace-spans.md
jpwilliams Oct 13, 2026
477822c
Name a job's span from its config, keeping the number of later direct…
Oct 13, 2026
5d039f3
Drop changeset: ci-shared-helpers is a pure internal refactor
jpwilliams Oct 13, 2026
b52b8d3
Merge jack/ci-trace-spans-v2 into jack/ci-shared-helpers
jpwilliams Oct 13, 2026
de246e7
Merge remote-tracking branch 'origin/main' into jack/ci-shared-helpers
jpwilliams Oct 13, 2026
b320301
Merge jack/ci-shared-helpers into jack/ci-checkout-repo-v3
jpwilliams Oct 13, 2026
82541b6
Merge origin/main into jack/ci-checkout-repo-v3 (tree already identic…
jpwilliams Oct 13, 2026
77ecbcd
docs(ci): rewrite checkout-repo changeset as a release note
jpwilliams Oct 13, 2026
24cecbd
fix(ci): treat 422 as "not in this installation"
jpwilliams Oct 13, 2026
317b106
fix(ci): validate files() repo when it is defined
jpwilliams Oct 13, 2026
6fd3853
fix(ci): allow underscores in repository owners
jpwilliams Oct 13, 2026
8aad8aa
refactor(ci): replace checkout sources with a plain if
jpwilliams Oct 13, 2026
cdf6c96
refactor(ci): resolve the default branch inside ref resolution
jpwilliams Oct 13, 2026
f75f973
refactor(ci): table the repeated source tests
jpwilliams Oct 13, 2026
22a0510
fix(ci): hand builds the invoker's resolved sources; reject empty rep…
jpwilliams Oct 14, 2026
9c9fedc
fix(ci): say a truncated tree needs a string key; test builds reuse t…
jpwilliams Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/ci-checkout-repo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@inngest/ci": minor
---

`checkout()` and `files()` can now read another repository or ref, including in runs whose trigger has no repository.

```ts
await checkout({ repo: "acme/shared-config", ref: "main" });
```
24 changes: 24 additions & 0 deletions packages/ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ const test = ci.job({ id: "test", from: base }, async () => {

A pipeline run is one trace. `lint` and `test` start from a snapshot of `base`, and a failed command runs again without rerunning the jobs that passed.

## Another repository or ref

`checkout()` and `files()` take `repo` and `ref`. A ref is a branch, tag or commit, and it's resolved to a commit once per run.

```ts
await checkout({ repo: "acme/platform" }); // default branch
await checkout({ repo: "acme/platform", ref: "v2.1.0" });
await checkout({ ref: "main" }); // this repository

const image = ci.job(
{
id: "image",
cache: {
key: files("images/node-base/**", { repo: "acme/platform", ref: "main" }),
},
},
async () => {
await checkout({ repo: "acme/platform", ref: "main" });
},
);
```

A run whose trigger has no repository, like a cron, needs `repo`. The GitHub App must have access to the repository. Locally, `checkout()` still uploads your working tree unless you name another repository or a `ref`.

## Install

```bash
Expand Down
151 changes: 131 additions & 20 deletions packages/ci/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,33 @@
* @module
*/

import { NonRetriableError } from "inngest";
import { mapGitHubError, rest } from "../github/rest.ts";
import type { ResolvedSource } from "../github/source.ts";
import {
octokitForSource,
resolveSource,
targetsRunRepo,
} from "../github/source.ts";
import { ciRun } from "../pipeline/metadata.ts";
import type { StepSpec } from "../pipeline/names.ts";
import { steps } from "../pipeline/names.ts";
import type { CiJobScope, CiRunScope } from "../pipeline/scope.ts";
import { countApi, joinId, rootRunIdOf } from "../pipeline/scope.ts";
import { countApi, joinId, localRepo, rootRunIdOf } from "../pipeline/scope.ts";
import type {
CacheConfig,
CacheKey,
CacheKeyPart,
FilesOptions,
JobConfig,
RepoContext,
} from "../types.ts";
import {
filterPaths,
formatRelative,
hash,
isSnapshotNotFound,
parseRepo,
stableStringify,
} from "../util.ts";
import { formatName } from "./names.ts";
Expand Down Expand Up @@ -87,8 +98,9 @@ const sharedScopes = (
* Resolve the parts of a cache key into a single hash.
*
* `files()` parts are resolved against the repository: the git tree for the
* run's commit on GitHub, or the working tree locally, so a key changes
* exactly when the matched files do.
* run's commit on GitHub (or for the commit its `repo` and `ref` resolved to),
* or the working tree locally, so a key changes exactly when the matched files
* do.
*/
export const resolveCacheKey = async (
run: CiRunScope,
Expand All @@ -102,47 +114,115 @@ export const resolveCacheKey = async (
return hash(await key());
}

const parts = Array.isArray(key) ? key : [key];
const resolved: string[] = [];

for (const part of parts) {
if (typeof part === "string") {
resolved.push(part);
for (const part of keyParts(key)) {
resolved.push(
typeof part === "string" ? part : await resolveFilesPart(run, part),
);
}

continue;
}
return hash(resolved.join("\0"));
};

resolved.push(await resolveFilesPart(run, part));
/** The parts of a cache key, which a function key doesn't have. */
const keyParts = (key: CacheKey | undefined): (CacheKeyPart | string)[] => {
if (key === undefined || typeof key === "function") {
return [];
}

return hash(resolved.join("\0"));
return Array.isArray(key) ? key : [key];
};

/**
* Resolve the repository and ref of every `files()` part that reads another
* commit than the run's own, each in a memoized step. A key is worked out
* inside a step, which can't start another, so this runs just before it.
*/
export const resolveKeySources = async (
run: CiRunScope,
key: CacheKey | undefined,
): Promise<void> => {
for (const part of keyParts(key)) {
if (typeof part !== "string" && !targetsRunRepo(run, part)) {
await resolveSource(run, part);
}
}
};

const resolveFilesPart = async (
run: CiRunScope,
part: CacheKeyPart,
): Promise<string> => {
const repo = run.repo;
if (!targetsRunRepo(run, part)) {
return hashRemoteFiles(run, await resolveSource(run, part), part);
}

const local = localRepo(run);

if (repo?.local && process.env.INNGEST_CI_GITHUB !== "live") {
if (local) {
const { hashLocalFiles } = await import("./localCache.ts");

return hashLocalFiles(repo.local.path, part.patterns);
return hashLocalFiles(local.path, part.patterns);
}

const repo = run.repo;

if (!repo) {
return `files:${part.patterns.join(",")}`;
}

const { rest } = await import("../github/rest.ts");
const { filterPaths } = await import("../util.ts");

const tree = await rest.git.getTree({
tree_sha: repo.sha,
recursive: "1",
});

const entries = (tree.tree ?? [])
return hashTree(tree, part.patterns, repo.fullName);
};

/**
* Hash the matched files of another repository or ref, at the commit it
* resolved to, with a client for the installation that can read it.
*/
const hashRemoteFiles = async (
run: CiRunScope,
source: ResolvedSource,
part: CacheKeyPart,
): Promise<string> => {
const octokit = await octokitForSource(run, source);

try {
const { data } = await octokit.rest.git.getTree({
owner: source.owner,
repo: source.name,
tree_sha: source.sha,
recursive: "1",
});

return hashTree(data, part.patterns, source.fullName);
} catch (error) {
throw mapGitHubError(error) ?? error;
}
};

/** A hash of the blobs in a git tree that match the patterns. */
const hashTree = (
listing: {
tree?: { type?: string; path?: string; sha?: string }[];
truncated?: boolean;
},
patterns: string[],
fullName: string,
): string => {
// A truncated listing would hash only some of the files, and a key that
// ignores the rest never changes when they do.
if (listing.truncated) {
throw new NonRetriableError(
`\`${fullName}\` is too large to hash \`files()\` from GitHub's tree API, which cut the file listing short. Key the job on a string instead.`,
);
}

const entries = (listing.tree ?? [])
.filter((entry) => {
return entry.type === "blob" && entry.path;
})
Expand All @@ -154,7 +234,7 @@ const resolveFilesPart = async (
entries.map((entry) => {
return entry.path;
}),
{ include: part.patterns },
{ include: patterns },
);

const byPath = new Map(
Expand Down Expand Up @@ -270,6 +350,8 @@ export const cacheTarget = async (

countApi("cache");

await resolveKeySources(run, cache.key);
Comment thread
jpwilliams marked this conversation as resolved.
Comment thread
jpwilliams marked this conversation as resolved.

const ownKey = await ciRun(run, steps.cacheKey(job.path), () => {
return jobCacheKey(run, cache, input, base);
});
Expand Down Expand Up @@ -710,14 +792,43 @@ export const describeCached = (
* Contents are read from the git tree for the run's commit, or from the
* working tree locally, so uncommitted changes change the key too.
*
* End with `{ repo, ref }` to read another repository or ref. `ref` is a
* branch, tag or commit, and `repo` is `"owner/name"` with `ref` defaulting to
* its default branch. The ref is resolved to a commit once per run, so a branch
* that moves changes the key exactly when the matched files change. Locally,
* this reads from GitHub too, unless it names the local repository with no
* `ref`.
*
* ```ts
* cache: {
* key: files("images/node-base/**", { repo: "acme/platform", ref: "main" }),
* }
* ```
*
* Patterns support `**`, `*`, `?`, and `{a,b}`.
*/
export const files = (
/** Glob patterns for the files to hash. */ ...patterns: string[]
/** Glob patterns for the files to hash, then optionally where to read them from. */
...args: string[] | [...patterns: string[], options: FilesOptions]
): CacheKeyPart => {
const list: (string | FilesOptions)[] = args;
const last = list.at(-1);
const options = typeof last === "object" ? last : undefined;

// Checked here, where the pipeline is defined, so a bad `repo` fails at boot.
if (options?.repo !== undefined) {
parseRepo(options.repo);
}

const patterns = list.filter((arg): arg is string => {
return typeof arg === "string";
});

return {
kind: "inngest/ci.cacheKeyPart",
type: "files",
patterns,
...(options?.repo === undefined ? {} : { repo: options.repo }),
...(options?.ref === undefined ? {} : { ref: options.ref }),
};
};
Loading
Loading