import { describe, expect, mock, test } from "bun:test"; mock.module("cloudflare:workers", () => ({ env: {}, waitUntil: () => undefined, WorkflowEntrypoint: class {}, DurableObject: class {}, WorkerEntrypoint: class {}, })); const { parseActionReference, isPinned, looksLikeShortSha } = await import("./uses"); const { parseActionManifest, inputVariable } = await import("./manifest"); const { checkPolicy, workflowActionUses } = await import("./resolve"); const { parseGithubRefs, pickRef, namesAsParent } = await import("./github"); const { buildArchive, archiveKey } = await import("./archive"); const { compileWorkflow } = await import("../compile"); const { buildJobScript } = await import("../script"); const { parseWorkflow } = await import("../spec"); const { addMaskValue, withAddedMask, MAX_ADDED_MASKS } = await import("../markers"); const { buildPack, makeBlob, makeCommit, makeTree } = await import("../../git/objects"); const { parsePack } = await import("../../git/pack"); const { concatBytes, FLUSH_PKT, pktLine } = await import("../../git/pktline"); const SHA = "1".repeat(40); const OTHER = "2".repeat(40); const FAN_OUT_DEPTH = 7; const JS_MANIFEST = `name: Setup Vim inputs: version: default: stable token: default: \${{ github.token }} runs: using: node24 main: dist/index.js post: dist/post.js `; describe("uses references", () => { test("parses owner/repo, subpaths, local and docker forms", () => { expect(parseActionReference("rhysd/action-setup-vim@v1")).toEqual({ kind: "remote", owner: "rhysd", repo: "action-setup-vim", path: "", ref: "v1", }); expect(parseActionReference(`actions/cache/restore@${SHA}`)).toMatchObject({ path: "restore", ref: SHA }); expect(parseActionReference("./.github/actions/x")).toEqual({ kind: "local", path: ".github/actions/x" }); expect(parseActionReference("docker://alpine:3")).toEqual({ kind: "docker", image: "alpine:3" }); }); test("rejects missing versions and path traversal", () => { expect(() => parseActionReference("owner/repo")).toThrow("needs a version"); expect(() => parseActionReference("owner/repo/../x@v1")).toThrow(); expect(() => parseActionReference("./../outside")).toThrow(); expect(() => parseActionReference("owner/repo@v1..v2")).toThrow(); }); test("only full SHAs pin", () => { expect(isPinned(SHA)).toBe(true); expect(isPinned("v1")).toBe(false); expect(isPinned("1234567")).toBe(false); expect(looksLikeShortSha("1234567")).toBe(true); }); }); describe("action manifests", () => { test("reads a JavaScript action", () => { const manifest = parseActionManifest(JS_MANIFEST, "x"); expect(manifest).toMatchObject({ runtime: "node24", main: "dist/index.js", post: "dist/post.js", pre: null }); expect(manifest.inputs.map((input) => input.name)).toEqual(["version", "token"]); }); test("rejects composite and escaping entry points", () => { expect(() => parseActionManifest("runs:\n using: composite\n steps: []\n", "x")).toThrow("composite"); expect(() => parseActionManifest("runs:\n using: docker\n", "x")).toThrow("runs.image"); expect(() => parseActionManifest("runs:\n using: docker\n image: ../Dockerfile\n", "x")).toThrow("relative"); expect(() => parseActionManifest("runs:\n using: node20\n main: ../../etc/x.js\n", "x")).toThrow("relative"); expect(() => parseActionManifest("runs:\n using: node20\n main: /abs.js\n", "x")).toThrow("relative"); }); test("input variables keep dashes and upper-case like the toolkit", () => { expect(inputVariable("node-version")).toBe("INPUT_NODE-VERSION"); expect(inputVariable("my input")).toBe("INPUT_MY_INPUT"); }); }); describe("policy", () => { test("pinned policy refuses tags but allows SHAs and local actions", () => { expect(() => checkPolicy("owner/repo@v1", "pinned")).toThrow("full commit SHA"); expect(() => checkPolicy(`owner/repo@${SHA}`, "pinned")).not.toThrow(); expect(() => checkPolicy("./local", "pinned")).not.toThrow(); }); test("builtin policy refuses every fetched action; short SHAs are always refused", () => { expect(() => checkPolicy(`owner/repo@${SHA}`, "builtin")).toThrow("built-in"); expect(() => checkPolicy("owner/repo@abc1234", "all")).toThrow("short commit SHAs"); expect(() => checkPolicy("docker://alpine", "all")).not.toThrow(); expect(() => checkPolicy("docker://alpine", "builtin")).toThrow("built-in"); expect(() => checkPolicy("docker://alpine:3", "pinned")).toThrow("digest"); expect(() => checkPolicy(`docker://alpine@sha256:${"a".repeat(64)}`, "pinned")).not.toThrow(); }); test("setup-node is built in unless a version is requested", () => { const parsed = parseWorkflow(`on: push jobs: a: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - uses: actions/setup-node@v4 with: node-version: 20 - uses: rhysd/action-setup-vim@v1 `); if (!parsed.ok) throw new Error(parsed.error); expect(workflowActionUses(parsed.spec)).toEqual(["actions/setup-node@v4", "rhysd/action-setup-vim@v1"]); }); }); describe("github protocol helpers", () => { const body = concatBytes([ pktLine(`${SHA} refs/heads/v1\n`), pktLine(`${OTHER} refs/tags/v1 peeled:${SHA.replace(/1/g, "3")}\n`), FLUSH_PKT, ]); test("tags win over branches and resolve to the peeled commit", () => { const refs = parseGithubRefs(body); expect(pickRef(refs, "v1")).toBe("3".repeat(40)); expect(pickRef(refs, "v2")).toBeNull(); }); test("reachability: a pinned commit is accepted only when a fetched commit names it as parent", async () => { const tree = await makeTree([]); const signature = { name: "a", email: "a@x", timestamp: 1, timezone: "+0000" }; const pinned = await makeCommit({ tree: tree.sha, parents: [], author: signature, committer: signature, message: "a", }); const child = await makeCommit({ tree: tree.sha, parents: [pinned.sha], author: signature, committer: signature, message: "b", }); const parsed = await parsePack(await buildPack([child]), { maxObjects: 10, maxObjectBytes: 1024, maxTotalBytes: 4096, }); expect(namesAsParent(parsed, pinned.sha)).toBe(true); expect(namesAsParent(parsed, OTHER)).toBe(false); }); }); describe("archives", () => { test("builds a verified archive with manifests and skips symlinks", async () => { const manifest = await makeBlob(JS_MANIFEST); const main = await makeBlob("console.log(1)"); const link = await makeBlob("/etc/passwd"); const dist = await makeTree([{ mode: "100644", name: "index.js", sha: main.sha }]); const sub = await makeTree([{ mode: "100644", name: "action.yml", sha: manifest.sha }]); const root = await makeTree([ { mode: "100644", name: "action.yml", sha: manifest.sha }, { mode: "40000", name: "dist", sha: dist.sha }, { mode: "40000", name: "restore", sha: sub.sha }, { mode: "120000", name: "evil", sha: link.sha }, ]); const signature = { name: "a", email: "a@x", timestamp: 1_700_000_000, timezone: "+0000" }; const commit = await makeCommit({ tree: root.sha, parents: [], author: signature, committer: signature, message: "x", }); const pack = await buildPack([manifest, main, link, dist, sub, root, commit]); const parsed = await parsePack(pack, { maxObjects: 100, maxObjectBytes: 1 << 20, maxTotalBytes: 1 << 22 }); const built = await buildArchive(parsed, commit.sha); expect(built.manifests.map((item) => item.path).sort()).toEqual(["", "restore"]); const again = await buildArchive(parsed, commit.sha); expect(again.tgz).toEqual(built.tgz); const tar = new Uint8Array( await new Response(new Blob([built.tgz]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer(), ); const text = new TextDecoder().decode(tar); expect(text).toContain("dist/index.js"); expect(text).not.toContain("evil"); }); test("a DAG of reused trees can't expand past the byte cap", async () => { const big = await makeBlob("x".repeat(1 << 20)); let level = await makeTree([{ mode: "100644", name: "f", sha: big.sha }]); const objects = [big, level]; for (let depth = 0; depth < FAN_OUT_DEPTH; depth++) { level = await makeTree([ { mode: "40000", name: "a", sha: level.sha }, { mode: "40000", name: "b", sha: level.sha }, ]); objects.push(level); } const signature = { name: "a", email: "a@x", timestamp: 1, timezone: "+0000" }; const commit = await makeCommit({ tree: level.sha, parents: [], author: signature, committer: signature, message: "x", }); const pack = await buildPack([...objects, commit]); const parsed = await parsePack(pack, { maxObjects: 100, maxObjectBytes: 1 << 21, maxTotalBytes: 1 << 22 }); await expect(buildArchive(parsed, commit.sha)).rejects.toThrow("larger than 64 MB"); }); test("refuses a commit that isn't in the verified objects", async () => { await expect(buildArchive({ objects: [], unresolved: [], truncated: false }, SHA)).rejects.toThrow(SHA); }); test("keys are case-insensitive on owner and repo", () => { expect(archiveKey("Rhysd", "Action-Setup-Vim", SHA)).toBe(`rhysd/action-setup-vim@${SHA}`); }); }); describe("compiling and scripting action steps", () => { const parsed = parseWorkflow(`on: push jobs: vim: steps: - id: vim uses: rhysd/action-setup-vim@v1 with: version: nightly `); if (!parsed.ok) throw new Error(parsed.error); const manifest = parseActionManifest(JS_MANIFEST, "rhysd/action-setup-vim@v1"); const actions = { "rhysd/action-setup-vim@v1": { uses: "rhysd/action-setup-vim@v1", local: false, repository: "rhysd/action-setup-vim", ref: "v1", sha: SHA, path: "", archive: { key: `rhysd/action-setup-vim@${SHA}`, sha256: "a".repeat(64) }, manifest, }, }; const [job] = compileWorkflow(parsed.spec, { event: { event_name: "push", ref_name: "main", workflow: "ci", event: { ref: "refs/heads/main" } }, inputs: {}, vars: {}, actions, }); test("records the pinned SHA and never gives an action's own token default the GitHub token", () => { const step = job.steps[0]; expect(step.kind).toBe("action"); expect(step.action?.sha).toBe(SHA); expect(step.action?.inputs.find(([name]) => name === "version")?.[1]).toEqual([{ kind: "text", value: "nightly" }]); expect(step.action?.inputs.find(([name]) => name === "token")?.[1]).toEqual([]); expect(job.eventJson).toBe(JSON.stringify({ ref: "refs/heads/main" })); }); test("github.token written in with: maps to the GITHUB_COM_TOKEN secret", () => { const explicit = parseWorkflow(`on: push jobs: vim: steps: - uses: rhysd/action-setup-vim@v1 with: token: \${{ github.token }} `); if (!explicit.ok) throw new Error(explicit.error); const [compiled] = compileWorkflow(explicit.spec, { event: {}, inputs: {}, vars: {}, actions }); expect(compiled.steps[0].action?.inputs.find(([name]) => name === "token")?.[1]).toEqual([ { kind: "secret", name: "GITHUB_COM_TOKEN" }, ]); }); test("secrets.GITHUB_TOKEN written in with: also maps to GITHUB_COM_TOKEN, since actions call api.github.com", () => { const explicit = parseWorkflow(`on: push jobs: vim: steps: - uses: rhysd/action-setup-vim@v1 with: token: \${{ secrets.GITHUB_TOKEN }} `); if (!explicit.ok) throw new Error(explicit.error); const [compiled] = compileWorkflow(explicit.spec, { event: {}, inputs: {}, vars: {}, actions }); expect(compiled.steps[0].action?.inputs.find(([name]) => name === "token")?.[1]).toEqual([ { kind: "secret", name: "GITHUB_COM_TOKEN" }, ]); }); test("the script verifies the archive, runs main and post, and is valid bash", async () => { const script = buildJobScript(job); expect(script).toContain(`__gitfor_action_fetch 'rhysd/action-setup-vim@${SHA}' ${"a".repeat(64)}`); expect(script).toContain("'dist/index.js'"); expect(script).toContain("'dist/post.js'"); expect(script).toContain('"INPUT_VERSION=nightly"'); expect(script).toContain('"INPUT_TOKEN="'); expect(script).not.toContain("GITFOR_SECRET_GITHUB_COM_TOKEN"); const check = Bun.spawnSync(["bash", "-n"], { stdin: new TextEncoder().encode(script) }); expect(check.exitCode).toBe(0); }); test("an action's own defaults never resolve secrets, and odd input names are refused", () => { const sneaky = parseActionManifest( "inputs:\n key:\n default: ${{ secrets.DEPLOY_KEY }}\nruns:\n using: node20\n main: a.js\n", "x", ); const parsedSneaky = parseWorkflow("on: push\njobs:\n a:\n steps:\n - uses: o/x@v1\n"); if (!parsedSneaky.ok) throw new Error(parsedSneaky.error); const resolved = { ...actions["rhysd/action-setup-vim@v1"], uses: "o/x@v1", manifest: sneaky }; const [compiledSneaky] = compileWorkflow(parsedSneaky.spec, { event: {}, inputs: {}, vars: {}, actions: { "o/x@v1": resolved }, }); expect(compiledSneaky.steps[0].action?.inputs).toEqual([["key", []]]); const odd = parseWorkflow( 'on: push\njobs:\n a:\n steps:\n - uses: o/x@v1\n with:\n "a=b": x\n', ); if (!odd.ok) throw new Error(odd.error); expect(() => compileWorkflow(odd.spec, { event: {}, inputs: {}, vars: {}, actions: { "o/x@v1": resolved } }), ).toThrow("invalid input name"); }); test("an unresolved action fails compilation", () => { expect(() => compileWorkflow(parsed.spec, { event: {}, inputs: {}, vars: {} })).toThrow("not resolved"); }); }); describe("workflow commands", () => { test("::add-mask:: yields the value to mask", () => { expect(addMaskValue("::add-mask::s3cret-value")).toBe("s3cret-value"); expect(addMaskValue("echo ::add-mask::x")).toBeNull(); expect(addMaskValue("::add-mask::")).toBeNull(); expect(addMaskValue("::add-mask::e")).toBeNull(); }); test("added masks are deduplicated and capped", () => { expect(withAddedMask(["abcd"], 0, "abcd")).toBeNull(); expect(withAddedMask(["abcd"], 0, "wxyz")).toEqual(["abcd", "wxyz"]); expect(withAddedMask([], MAX_ADDED_MASKS, "wxyz")).toBeNull(); }); });