diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9b7f2bd6..fe5dceeb 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -9,6 +9,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -17,13 +20,13 @@ jobs: node-version: [22.x] steps: - uses: actions/checkout@v4 - - name: create env file - run: | - touch .env - echo TOKEN=${{ secrets.TOKEN }} >> .env - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm ci + # Pass the secret through env rather than interpolating it into a + # script or writing it to a .env file on disk. - run: npm test + env: + TOKEN: ${{ secrets.TOKEN }} diff --git a/README.md b/README.md index 4ee2a463..0e70f6cd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Please refer to the updated link [here](#how-to-use) - [Available Themes](#available-themes) - [Customization](#customization) - [Common Options](#common-options) +- [Use as a GitHub Action](#use-as-a-github-action) - [Deploy on your own Replit instance](#deploy-on-your-own-replit-instance) - [Follow the steps](#follow-the-steps) - [Deploy on your own Vercel instance](#deploy-on-your-own-vercel-instance) @@ -123,6 +124,49 @@ Example: [![Ashutosh's github activity graph](https://github-readme-activity-graph.vercel.app/graph?username=ashutosh00710&bg_color=fffff0&color=708090&line=24292e&point=24292e&area=true&border_color=ff0000)](https://github.com/ashutosh00710/github-readme-activity-graph) ``` +## Use as a GitHub Action + +Instead of relying on a hosted instance, you can render the graph to an SVG file in your own workflow and commit it to your profile repository. + +```yaml +name: Activity Graph + +on: + schedule: + - cron: '0 0 * * *' # once a day + workflow_dispatch: + +permissions: + contents: write + +jobs: + graph: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Ashutosh00710/github-readme-activity-graph@main + with: + username: ${{ github.repository_owner }} + token: ${{ secrets.GITHUB_TOKEN }} + output_path: profile/activity-graph.svg + theme: github-light + - name: Commit graph + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add profile/activity-graph.svg + git diff --cached --quiet || git commit -m "Update activity graph" + git push +``` + +Then reference the file from your README: + +```md +![Activity Graph](profile/activity-graph.svg) +``` + +Every option from [Common Options](#common-options) is available as an input with the same name (`custom_title`, `hide_border`, `days`, `area`, `bg_color`, ...). See [`action.yml`](action.yml) for the full list. `secrets.GITHUB_TOKEN` only sees public activity; use a personal access token to include private contributions. If fetching fails, the step fails instead of writing an error image. + ## Deploy on your own Replit instance
diff --git a/__test__/__snapshots__/svgs.test.ts.snap b/__test__/__snapshots__/svgs.test.ts.snap index 720ad071..1e562da9 100644 --- a/__test__/__snapshots__/svgs.test.ts.snap +++ b/__test__/__snapshots__/svgs.test.ts.snap @@ -164,7 +164,7 @@ exports[`SVG Testing Test SVGs 1`] = ` graphSvg - [object Promise] + line " `; diff --git a/__test__/cli.test.ts b/__test__/cli.test.ts new file mode 100644 index 00000000..c028bd92 --- /dev/null +++ b/__test__/cli.test.ts @@ -0,0 +1,86 @@ +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { optionsFromEnv, run } from '../src/cli'; +import { Fetcher } from '../src/fetcher'; + +jest.mock('../src/fetcher'); +const mockFetch = Fetcher.prototype.fetchContributions as jest.Mock; + +const contributions = Array.from({ length: 31 }, (_, i) => ({ + contributionCount: i % 5, + date: String(i + 1), +})); + +describe('CLI Tests', () => { + let dir: string; + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-graph-')); + mockFetch.mockReset(); + }); + afterEach(() => fs.rm(dir, { recursive: true, force: true })); + + it('maps ACTIVITY_* variables to query options', () => { + expect( + optionsFromEnv({ + ACTIVITY_USERNAME: 'octocat', + ACTIVITY_THEME: 'github-light', + ACTIVITY_CUSTOM_TITLE: "Octocat's graph", + ACTIVITY_HIDE_BORDER: 'TRUE', + ACTIVITY_AREA: 'false', + ACTIVITY_RADIUS: '8', + ACTIVITY_DAYS: '60', + }), + ).toEqual({ + username: 'octocat', + theme: 'github-light', + custom_title: "Octocat's graph", + hide_border: true, + area: false, + radius: 8, + days: '60', + }); + }); + + it('treats empty action inputs as unset', () => { + expect( + optionsFromEnv({ + ACTIVITY_USERNAME: 'octocat', + ACTIVITY_THEME: '', + ACTIVITY_HEIGHT: '', + ACTIVITY_HIDE_TITLE: ' ', + }), + ).toEqual({ username: 'octocat' }); + }); + + it('writes the graph SVG to output_path, creating directories', async () => { + mockFetch.mockResolvedValue({ name: 'The Octocat', contributions }); + const outputPath = path.join(dir, 'nested', 'graph.svg'); + + await run({ + TOKEN: 'token', + ACTIVITY_USERNAME: 'octocat', + ACTIVITY_OUTPUT_PATH: outputPath, + }); + + const svg = await fs.readFile(outputPath, 'utf8'); + expect(svg).toContain(' { + mockFetch.mockResolvedValue(`Can't fetch any contribution. Please check your username 😬`); + const outputPath = path.join(dir, 'graph.svg'); + + await expect( + run({ TOKEN: 'token', ACTIVITY_USERNAME: 'nobody', ACTIVITY_OUTPUT_PATH: outputPath }), + ).rejects.toThrow("Can't fetch any contribution"); + await expect(fs.access(outputPath)).rejects.toThrow(); + }); + + it('requires a username and a token', async () => { + await expect(run({ TOKEN: 'token' })).rejects.toThrow('ACTIVITY_USERNAME is required'); + await expect(run({ ACTIVITY_USERNAME: 'octocat' })).rejects.toThrow('TOKEN is required'); + }); +}); diff --git a/__test__/fakeInputs.ts b/__test__/fakeInputs.ts index 3346aec1..7cf8dd8f 100644 --- a/__test__/fakeInputs.ts +++ b/__test__/fakeInputs.ts @@ -316,7 +316,7 @@ export let fakeGraphArgs: GraphArgs = { pointColor: 'bd93f9', }, title: 'graphSvg', - line: Promise.resolve('line'), + line: 'line', }; export let options = { diff --git a/__test__/fetching.test.ts b/__test__/fetching.test.ts index 44b74814..f3695924 100644 --- a/__test__/fetching.test.ts +++ b/__test__/fetching.test.ts @@ -1,3 +1,4 @@ +import { format } from 'util'; import { Fetcher } from '../src/fetcher'; import { mockQueryCorrect, @@ -51,3 +52,37 @@ describe('Fetching Tests', () => { }); }); }); + +describe('Fetching error logging', () => { + // format() renders arguments the same way console.log does + const logged = (spy: jest.SpyInstance) => + spy.mock.calls.map((args) => format(...args)).join('\n'); + + it('never logs the token carried on a failed request', async () => { + const token = 'ghp_secretTokenValue123'; + // axios attaches the request config, including headers, to thrown errors + const error = Object.assign(new Error('Request failed with status code 401'), { + config: { headers: { Authorization: `bearer ${token}` } }, + }); + const fetcher = new Fetcher('octocat'); + // @ts-ignore: mocking private method + fetcher.fetch = jest.fn().mockRejectedValue(error); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await fetcher.fetchContributions(31); + await fetcher.fetchContributions(31); + // @ts-ignore: mocking private method + fetcher.fetch = jest.fn().mockRejectedValue(error.config); + await fetcher.fetchContributions(31); + + const output = logged(logSpy) + logged(errorSpy); + expect(output).toContain('Request failed with status code 401'); + expect(output).not.toContain(token); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); +}); diff --git a/__test__/injection.test.ts b/__test__/injection.test.ts new file mode 100644 index 00000000..92833f54 --- /dev/null +++ b/__test__/injection.test.ts @@ -0,0 +1,69 @@ +import { Utilities } from '../src/utils'; +import { escapeXml } from '../src/svgs'; +import { selectColors } from '../src/styles/themes'; + +const contributions = Array.from({ length: 31 }, (_, i) => ({ + contributionCount: i % 5, + date: String(i + 1), +})); + +describe('SVG injection', () => { + it('escapes markup in text', () => { + expect(escapeXml(' & co')).toEqual( + '<script>alert("x")</script> & co', + ); + }); + + it('escapes custom_title in the rendered graph', async () => { + const utils = new Utilities({ + username: 'octocat', + custom_title: '', + }); + const { finalGraph } = await utils.buildGraph({ name: 'The Octocat', contributions }); + expect(finalGraph).not.toContain('