diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 0000000..6ce84bb --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "packages": { + ".": {} + }, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true +} diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json new file mode 100644 index 0000000..1c56072 --- /dev/null +++ b/.github/release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..43a7910 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Prepare Release + +on: + push: + branches: + - main + +permissions: {} + +jobs: + release-please: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: write + issues: write + pull-requests: write + outputs: + pr_branch: ${{ steps.pr-branch.outputs.branch }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Run release-please action + id: release-please-action + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: .github/release-please-config.json + manifest-file: .github/release-please-manifest.json + + - name: Extract PR branch + id: pr-branch + if: steps.release-please-action.outputs.pr + env: + PR_JSON: ${{ steps.release-please-action.outputs.pr }} + run: | + branch=$(echo "$PR_JSON" | jq -r '.headBranchName') + echo "branch=${branch}" >> $GITHUB_OUTPUT + + build-action: + needs: release-please + if: needs.release-please.outputs.pr_branch + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: write + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Checkout PR branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.release-please.outputs.pr_branch }} + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Use Node.js 24.x + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.x + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Commit dist to PR branch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + bot_id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + git config user.name "${APP_SLUG}[bot]" + git config user.email "${bot_id}+${APP_SLUG}[bot]@users.noreply.github.com" + gh auth setup-git + git add dist/ + git diff --staged --quiet || git commit -m "chore: build dist" + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fe748ad --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + id-token: write # For Codecov upload + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + + - name: Use Node.js 24.x + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.x + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: node --run test:coverage + + - name: Upload coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + use_oidc: true + fail_ci_if_error: true + files: ./lcov.info diff --git a/.gitignore b/.gitignore index c2658d7..d3f7488 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +lcov.info diff --git a/.oxlintrc.json b/.oxlintrc.json index 303b7fc..714a740 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,5 +22,10 @@ "no-duplicate-imports": "error" }, "ignorePatterns": ["dist/", "node_modules/"], - "overrides": [{ "files": ["**/*.test.ts"], "rules": { "no-floating-promises": "off" } }] + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { "no-floating-promises": "off", "require-await": "off" } + } + ] } diff --git a/package.json b/package.json index 2a6762a..7198f1a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "format": "oxfmt --write .", "check:types": "tsc --noEmit", "check:format": "oxfmt --check .", - "build": "rolldown -c rolldown.config.ts" + "build": "rolldown -c rolldown.config.ts", + "test": "node --experimental-test-module-mocks --test src/**/*.test.ts", + "test:coverage": "node --experimental-test-module-mocks --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test src/**/*.test.ts" }, "devDependencies": { "@actions/core": "^3.0.1", diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..7c6fedb --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,184 @@ +import { after, beforeEach, describe, mock, test } from "node:test"; +import assert from "node:assert/strict"; + +const getInputMock = mock.fn<(name: string, options?: { required?: boolean }) => string>(); +const getBooleanInputMock = mock.fn<(name: string) => boolean>(); + +mock.module("@actions/core", { + exports: { + getInput: getInputMock, + getBooleanInput: getBooleanInputMock, + }, +}); + +const { getConfig } = await import("./config.ts"); + +function setHub(value: string) { + getInputMock.mock.mockImplementation(() => value); +} + +function setFlags(syncRepo: boolean, pullImages: boolean) { + getBooleanInputMock.mock.mockImplementation((name: string) => { + if (name === "syncRepo") { + return syncRepo; + } + if (name === "pullImages") { + return pullImages; + } + return false; + }); +} + +beforeEach(() => { + getInputMock.mock.resetCalls(); + getBooleanInputMock.mock.resetCalls(); + setHub("https://orca.example.com"); + setFlags(true, false); +}); + +after(() => { + mock.restoreAll(); +}); + +describe("getConfig()", () => { + describe("URL normalization: trailing slashes", () => { + test("strips a single trailing slash", () => { + setHub("https://orca.example.com/"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + + test("strips multiple trailing slashes", () => { + setHub("https://orca.example.com///"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + + test("leaves URL without trailing slash unchanged", () => { + setHub("https://orca.example.com"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + }); + + describe("URL normalization: protocol", () => { + test("prepends https:// when no protocol is given", () => { + setHub("orca.example.com"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + + test("prepends https:// and strips trailing slash together", () => { + setHub("orca.example.com/"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + + test("preserves explicit https:// protocol", () => { + setHub("https://orca.example.com"); + const result = getConfig(); + assert.equal(result.hubUrl, "https://orca.example.com"); + }); + + test("preserves explicit http:// protocol", () => { + setHub("http://orca.example.com"); + const result = getConfig(); + assert.equal(result.hubUrl, "http://orca.example.com"); + }); + }); + + describe("URL validation: path component", () => { + test("throws when URL contains a path component", () => { + setHub("https://orca.example.com/some/path"); + assert.throws( + () => getConfig(), + (err: Error) => { + assert(err instanceof Error); + assert(err.message.includes("contains a path")); + return true; + }, + ); + }); + + test("throws when bare hostname has a path component", () => { + setHub("orca.example.com/path"); + assert.throws( + () => getConfig(), + (err: Error) => { + assert(err instanceof Error); + assert(err.message.includes("contains a path")); + return true; + }, + ); + }); + }); + + describe("URL validation: invalid URL", () => { + test("throws TypeError for a syntactically invalid URL", () => { + setHub("not a valid url with spaces"); + assert.throws(() => getConfig(), TypeError); + }); + }); + + describe("flag validation", () => { + test("throws when both syncRepo and pullImages are false", () => { + setFlags(false, false); + assert.throws( + () => getConfig(), + (err: Error) => { + assert(err instanceof Error); + assert.equal(err.message, "At least one of syncRepo or pullImages must be set to true."); + return true; + }, + ); + }); + + test("succeeds when only syncRepo is true", () => { + setFlags(true, false); + const result = getConfig(); + assert.equal(result.syncRepo, true); + assert.equal(result.pullImages, false); + }); + + test("succeeds when only pullImages is true", () => { + setFlags(false, true); + const result = getConfig(); + assert.equal(result.syncRepo, false); + assert.equal(result.pullImages, true); + }); + + test("succeeds when both are true", () => { + setFlags(true, true); + const result = getConfig(); + assert.equal(result.syncRepo, true); + assert.equal(result.pullImages, true); + }); + }); + + describe("return value shape", () => { + test("constructs endpoint from hubUrl", () => { + setHub("https://orca.example.com"); + const result = getConfig(); + assert.equal(result.endpoint, "https://orca.example.com/api/v1/github-actions"); + }); + + test("endpoint uses the normalized hubUrl (no trailing slash)", () => { + setHub("orca.example.com/"); + const result = getConfig(); + assert.equal(result.endpoint, "https://orca.example.com/api/v1/github-actions"); + }); + + test("calls getInput once with 'hub'", () => { + getConfig(); + assert.equal(getInputMock.mock.callCount(), 1); + assert.equal(getInputMock.mock.calls[0]?.arguments[0], "hub"); + }); + + test("calls getBooleanInput for 'syncRepo' and 'pullImages'", () => { + getConfig(); + const names = getBooleanInputMock.mock.calls.map((c) => c.arguments[0]); + assert(names.includes("syncRepo")); + assert(names.includes("pullImages")); + }); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..729bba2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,31 @@ +import * as core from "@actions/core"; + +type Config = { + hubUrl: string; + endpoint: string; + syncRepo: boolean; + pullImages: boolean; +}; + +export function getConfig(): Config { + let hubUrl = core.getInput("hub", { required: true }).trim().replace(/\/+$/, ""); + if (!hubUrl.startsWith("http://") && !hubUrl.startsWith("https://")) { + hubUrl = `https://${hubUrl}`; + } + + const parsed = new URL(hubUrl); + if (parsed.pathname !== "/") { + throw new Error( + `Invalid hub URL: "${hubUrl}" contains a path ("${parsed.pathname}"). Provide only the origin, e.g. https://orca.example.com`, + ); + } + + const syncRepo = core.getBooleanInput("syncRepo"); + const pullImages = core.getBooleanInput("pullImages"); + + if (!syncRepo && !pullImages) { + throw new Error("At least one of syncRepo or pullImages must be set to true."); + } + + return { hubUrl, endpoint: `${hubUrl}/api/v1/github-actions`, syncRepo, pullImages }; +} diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..4adfb36 --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,219 @@ +import { after, beforeEach, describe, mock, test } from "node:test"; +import assert from "node:assert/strict"; + +// ── @actions/core mock ────────────────────────────────────────────────────── +const startGroupMock = mock.fn<(name: string) => void>(); +const endGroupMock = mock.fn<() => void>(); +const infoMock = mock.fn<(msg: string) => void>(); +const setFailedMock = mock.fn<(msg: string | Error) => void>(); +const setSecretMock = mock.fn<(secret: string) => void>(); +const getIDTokenMock = mock.fn<(aud?: string) => Promise>(async () => "mock-oidc-token"); + +mock.module("@actions/core", { + exports: { + startGroup: startGroupMock, + endGroup: endGroupMock, + info: infoMock, + setFailed: setFailedMock, + setSecret: setSecretMock, + getIDToken: getIDTokenMock, + }, +}); + +// ── config.ts mock ────────────────────────────────────────────────────────── +const defaultConfig = { + hubUrl: "https://orca.example.com", + endpoint: "https://orca.example.com/api/v1/github-actions", + syncRepo: true, + pullImages: false, +}; + +const getConfigMock = mock.fn(() => ({ ...defaultConfig })); + +mock.module("./config.ts", { + exports: { getConfig: getConfigMock }, +}); + +// ── main.ts import (after all mocks) ─────────────────────────────────────── +const { run } = await import("./main.ts"); + +// ── fetch mock ────────────────────────────────────────────────────────────── +const fetchMock = mock.fn(); +globalThis.fetch = fetchMock; + +function makeResponse(ok: boolean, status: number, body: unknown, jsonThrows = false): Response { + return { + ok, + status, + statusText: String(status), + json: jsonThrows ? () => Promise.reject(new Error("not json")) : () => Promise.resolve(body), + } as unknown as Response; +} + +function resetAllMocks() { + startGroupMock.mock.resetCalls(); + endGroupMock.mock.resetCalls(); + infoMock.mock.resetCalls(); + setFailedMock.mock.resetCalls(); + setSecretMock.mock.resetCalls(); + getIDTokenMock.mock.resetCalls(); + getConfigMock.mock.resetCalls(); + fetchMock.mock.resetCalls(); +} + +beforeEach(() => { + resetAllMocks(); + getIDTokenMock.mock.mockImplementation(async () => "mock-oidc-token"); + getConfigMock.mock.mockImplementation(() => ({ ...defaultConfig })); + fetchMock.mock.mockImplementation(async () => + makeResponse(true, 200, { message: "Deployment triggered." }), + ); +}); + +after(() => { + mock.restoreAll(); +}); + +describe("run()", () => { + describe("happy path", () => { + test("calls getIDToken with the hubUrl as audience", async () => { + await run(); + assert.equal(getIDTokenMock.mock.callCount(), 1); + assert.equal(getIDTokenMock.mock.calls[0]?.arguments[0], "https://orca.example.com"); + }); + + test("masks the OIDC token with setSecret", async () => { + await run(); + assert.equal(setSecretMock.mock.callCount(), 1); + assert.equal(setSecretMock.mock.calls[0]?.arguments[0], "mock-oidc-token"); + }); + + test("POSTs to the correct endpoint", async () => { + await run(); + assert.equal(fetchMock.mock.callCount(), 1); + assert.equal( + fetchMock.mock.calls[0]?.arguments[0], + "https://orca.example.com/api/v1/github-actions", + ); + const init = fetchMock.mock.calls[0]?.arguments[1] as RequestInit; + assert.equal(init.method, "POST"); + }); + + test("sends correct headers and body", async () => { + await run(); + const init = fetchMock.mock.calls[0]?.arguments[1] as RequestInit; + const headers = init.headers as Record; + assert.equal(headers["Authorization"], "Bearer mock-oidc-token"); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers["Accept"], "application/json"); + assert.equal(headers["User-Agent"], "OrcaCD Deploy GitHub Action"); + assert.equal(init.body, JSON.stringify({ syncRepo: true, pullImages: false })); + }); + + test("logs the message from the response body", async () => { + await run(); + const infoCalls = infoMock.mock.calls.map((c) => c.arguments[0]); + assert(infoCalls.includes("Deployment triggered.")); + }); + + test("falls back to default success message when body has no message field", async () => { + fetchMock.mock.mockImplementation(async () => makeResponse(true, 200, {})); + await run(); + const infoCalls = infoMock.mock.calls.map((c) => c.arguments[0]); + assert(infoCalls.includes("Deployment triggered successfully.")); + }); + + test("calls startGroup and endGroup", async () => { + await run(); + assert.equal(startGroupMock.mock.callCount(), 1); + assert.equal(endGroupMock.mock.callCount(), 1); + }); + + test("does not call setFailed on success", async () => { + await run(); + assert.equal(setFailedMock.mock.callCount(), 0); + }); + }); + + describe("HTTP error responses", () => { + test("uses error message from response body", async () => { + fetchMock.mock.mockImplementation(async () => + makeResponse(false, 401, { message: "Invalid token" }), + ); + await run(); + assert.equal(setFailedMock.mock.callCount(), 1); + assert.equal( + setFailedMock.mock.calls[0]?.arguments[0], + "Can not start deployment. Hub returned HTTP 401: Invalid token", + ); + }); + + test("falls back to (empty body) when error response has no message", async () => { + fetchMock.mock.mockImplementation(async () => makeResponse(false, 500, {})); + await run(); + assert.equal( + setFailedMock.mock.calls[0]?.arguments[0], + "Can not start deployment. Hub returned HTTP 500: (empty body)", + ); + }); + + test("falls back to (empty body) when error response body is not JSON", async () => { + fetchMock.mock.mockImplementation(async () => makeResponse(false, 503, null, true)); + await run(); + assert.equal( + setFailedMock.mock.calls[0]?.arguments[0], + "Can not start deployment. Hub returned HTTP 503: (empty body)", + ); + }); + + test("still calls endGroup even on error response", async () => { + fetchMock.mock.mockImplementation(async () => makeResponse(false, 500, {})); + await run(); + assert.equal(endGroupMock.mock.callCount(), 1); + }); + }); + + describe("30-second abort timeout", () => { + test("passes an AbortSignal to fetch", async () => { + await run(); + const init = fetchMock.mock.calls[0]?.arguments[1] as RequestInit; + assert(init.signal instanceof AbortSignal); + }); + + test("calls setFailed when fetch is aborted", async () => { + fetchMock.mock.mockImplementation(() => + Promise.reject(new DOMException("This operation was aborted", "AbortError")), + ); + await run(); + assert.equal(setFailedMock.mock.callCount(), 1); + assert.match(String(setFailedMock.mock.calls[0]?.arguments[0]), /aborted|abort/i); + }); + }); + + describe("error propagation", () => { + test("calls setFailed when getConfig throws", async () => { + getConfigMock.mock.mockImplementationOnce(() => { + throw new Error("Invalid hub URL"); + }); + await run(); + assert.equal(setFailedMock.mock.calls[0]?.arguments[0], "Invalid hub URL"); + }); + + test("calls setFailed when getIDToken throws", async () => { + getIDTokenMock.mock.mockImplementationOnce(async () => { + throw new Error("OIDC failed"); + }); + await run(); + assert.equal(setFailedMock.mock.calls[0]?.arguments[0], "OIDC failed"); + }); + + test("handles non-Error thrown values", async () => { + getConfigMock.mock.mockImplementationOnce(() => { + // oxlint-disable-next-line no-throw-literal + throw "a raw string error"; + }); + await run(); + assert.equal(setFailedMock.mock.calls[0]?.arguments[0], "a raw string error"); + }); + }); +}); diff --git a/src/main.ts b/src/main.ts index b430e2d..6a47609 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,67 @@ import * as core from "@actions/core"; +import { getConfig } from "./config.ts"; + +const timeoutMs = 30_000; // 30 seconds export async function run(): Promise { try { - // Todo - await new Promise((resolve) => setTimeout(resolve, 1000)); + const { hubUrl, endpoint, syncRepo, pullImages } = getConfig(); + + const token = await core.getIDToken(hubUrl); + core.setSecret(token); // Prevent token from being logged + + core.startGroup("Triggering Deployment"); + try { + core.info(`Hub: ${hubUrl}`); + core.info(`syncRepo: ${syncRepo}, pullImages: ${pullImages}`); + + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + Accept: "application/json", + "User-Agent": "OrcaCD Deploy GitHub Action", + }, + body: JSON.stringify({ syncRepo, pullImages }), + signal: abortController.signal, + }); + } finally { + clearTimeout(timeout); + } + + const json: unknown = await response.json().catch(() => null); + const message = extractMessage(json); + + if (!response.ok) { + throw new Error( + `Can not start deployment. Hub returned HTTP ${response.status}: ${message ?? "(empty body)"}`, + ); + } + + core.info(message ?? "Deployment triggered successfully."); + } finally { + core.endGroup(); + } } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); + core.setFailed(error instanceof Error ? error.message : String(error)); + } +} + +function extractMessage(json: unknown): string | undefined { + if ( + json !== null && + typeof json === "object" && + "message" in json && + typeof (json as Record).message === "string" + ) { + return (json as Record).message; } } diff --git a/tsconfig.json b/tsconfig.json index bae1b1d..ed46e19 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "module": "nodenext", "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "types": ["node"] } }