From 514c46a109306458e65b68ff1264396d959741bf Mon Sep 17 00:00:00 2001 From: Edmond O'Flynn Date: Mon, 22 Jun 2026 18:26:13 +0200 Subject: [PATCH] test: add hermetic git e2e baseline --- .github/workflows/ci.yml | 7 +- .github/workflows/master.yml | 4 +- e2e/action.e2e.test.ts | 169 ++++++++++++++++++++++++++++++ e2e/harness.ts | 195 +++++++++++++++++++++++++++++++++++ eslint.config.mjs | 10 +- jest.e2e.config.js | 7 ++ package.json | 6 +- tsconfig.e2e.json | 8 ++ 8 files changed, 393 insertions(+), 13 deletions(-) create mode 100644 e2e/action.e2e.test.ts create mode 100644 e2e/harness.ts create mode 100644 jest.e2e.config.js create mode 100644 tsconfig.e2e.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4c592a..991e488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,11 +28,14 @@ jobs: - name: Check types run: npm run typecheck - - name: Run unit tests - run: npm test + - name: Run unit and end-to-end tests + run: npm test && npm run test:e2e - name: Audit production dependencies run: npm run audit:prod - name: Build and package action run: npm run build && npm run package + + - name: Build Docker action + run: docker build . diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 977b030..25c9c3a 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -26,7 +26,7 @@ jobs: run: npm run typecheck - name: Run tests - run: npm run test:coverage + run: npm run test:coverage && npm run test:e2e - name: Audit production dependencies run: npm run audit:prod @@ -34,6 +34,8 @@ jobs: - name: Ensure build & shrinkwrap work run: npm run build && npm run package + - name: Build Docker action + run: docker build . - name: Bump version id: bump_version diff --git a/e2e/action.e2e.test.ts b/e2e/action.e2e.test.ts new file mode 100644 index 0000000..f8b0665 --- /dev/null +++ b/e2e/action.e2e.test.ts @@ -0,0 +1,169 @@ +import fs from 'fs'; +import path from 'path'; +import { + ActionFixture, + cleanupFixture, + gitInRemote, + gitInWorkspace, + readOutput, + runActionFixture, +} from './harness'; + +describe('packaged action with local git repositories', () => { + const fixtures: ActionFixture[] = []; + const run = ( + options?: Parameters[0], + ): ActionFixture => { + const fixture = runActionFixture(options); + fixtures.push(fixture); + return fixture; + }; + + afterEach(() => { + fixtures.splice(0).forEach(cleanupFixture); + }); + + it('creates the initial version commit, tag, push, and output', () => { + const fixture = run({ unrelatedFile: true }); + expect(fixture.result.status).toBe(0); + expect(gitInRemote(fixture, 'show', 'main:version.properties')).toBe( + [ + 'majorVersion=0', + 'minorVersion=0', + 'patchVersion=1', + 'buildNumber=', + ].join('\n'), + ); + const remoteHead = gitInRemote(fixture, 'rev-parse', 'refs/heads/main'); + expect(gitInRemote(fixture, 'rev-parse', 'refs/tags/0.0.1')).toBe( + remoteHead, + ); + expect(gitInRemote(fixture, 'log', '-1', '--format=%s', 'main')).toBe( + 'release: v0.0.1 [skip-ci]', + ); + expect(readOutput(fixture)).toBe('new_tag=0.0.1'); + expect(gitInWorkspace(fixture, 'status', '--porcelain')).toBe( + '?? notes.txt', + ); + expect( + gitInRemote( + fixture, + 'diff-tree', + '--no-commit-id', + '--name-only', + '-r', + 'main', + ), + ).toBe('version.properties'); + }); + + it.each([ + ['patch', ['fix: repair launch'], '1.2.4'], + ['minor', ['feat: add login'], '1.3.0'], + [ + 'major precedence', + ['fix: repair launch', 'feat: add login', 'refactor!: remove v1'], + '2.0.0', + ], + ])( + 'performs a %s bump from the triggering commits', + (_, commits, version) => { + const fixture = run({ version: '1.2.3', commits }); + + expect(fixture.result.status).toBe(0); + expect( + gitInRemote(fixture, 'show', `${fixture.branch}:version.properties`), + ).toBe( + [ + `majorVersion=${version.split('.')[0]}`, + `minorVersion=${version.split('.')[1]}`, + `patchVersion=${version.split('.')[2]}`, + 'buildNumber=', + ].join('\n'), + ); + expect(gitInRemote(fixture, 'rev-parse', `refs/tags/${version}`)).toBe( + gitInRemote(fixture, 'rev-parse', `refs/heads/${fixture.branch}`), + ); + }, + ); + + it('applies inputs, identity, and head-ref checkout', () => { + const fixture = run({ + version: '1.2.3', + commits: ['feat: add login'], + headRef: 'feature/login', + inputs: { + tag_prefix: 'release-', + skip_ci: 'false', + build_number: '42', + commit_message: 'publish {{version}}', + }, + environment: { + GITHUB_USER: 'Release Bot', + GITHUB_EMAIL: 'release@example.com', + }, + }); + + expect(fixture.result.status).toBe(0); + expect( + gitInRemote(fixture, 'log', '-1', '--format=%s', fixture.branch), + ).toBe('publish release-1.3.0.42'); + expect( + gitInRemote(fixture, 'log', '-1', '--format=%an <%ae>', fixture.branch), + ).toBe('Release Bot '); + expect( + gitInRemote(fixture, 'show', `${fixture.branch}:version.properties`), + ).toContain('buildNumber=42'); + expect(readOutput(fixture)).toBe('new_tag=1.3.0.42'); + expect(gitInRemote(fixture, 'rev-parse', 'refs/heads/main')).not.toBe( + gitInRemote(fixture, 'rev-parse', `refs/heads/${fixture.branch}`), + ); + }); + + it('reports a rejected push and leaves the remote unchanged', () => { + const fixture = run({ + version: '1.2.3', + commits: ['fix: repair launch'], + rejectPush: true, + }); + + expect(fixture.result.status).toBe(1); + expect(gitInRemote(fixture, 'rev-parse', 'refs/heads/main')).toBe( + fixture.triggerSha, + ); + expect(gitInRemote(fixture, 'tag', '--list', '1.2.4')).toBe(''); + expect(gitInWorkspace(fixture, 'log', '-1', '--format=%s')).toBe( + 'release: v1.2.4 [skip-ci]', + ); + expect(gitInWorkspace(fixture, 'rev-parse', 'refs/tags/1.2.4')).toBe( + gitInWorkspace(fixture, 'rev-parse', 'HEAD'), + ); + expect(readOutput(fixture)).toBe(''); + expect(`${fixture.result.stdout}${fixture.result.stderr}`).toContain( + 'rejected by e2e git shim', + ); + }); + + it.skip('reads commit messages from real GitHub push payload objects (#122)', () => { + const fixture = run({ + version: '1.2.3', + commits: ['feat: add login'], + eventCommits: [{ id: 'fixture', message: 'feat: add login' }], + }); + + expect(fixture.result.status).toBe(0); + expect(gitInRemote(fixture, 'show', 'main:version.properties')).toContain( + 'minorVersion=3', + ); + }); + + it('keeps action output metadata mismatch documented for a later fix', () => { + const action = fs.readFileSync( + path.resolve(__dirname, '../action.yml'), + 'utf8', + ); + + expect(action).toContain(' newTag:'); + expect(action).not.toContain(' new_tag:'); + }); +}); diff --git a/e2e/harness.ts b/e2e/harness.ts new file mode 100644 index 0000000..7ec8705 --- /dev/null +++ b/e2e/harness.ts @@ -0,0 +1,195 @@ +import { execFileSync, spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const actionEntryPoint = path.resolve(__dirname, '../dist/index.js'); +const realGit = execFileSync('command', ['-v', 'git'], { + encoding: 'utf8', + shell: true, +}).trim(); + +type EventCommit = string | { id: string; message: string }; + +type FixtureOptions = { + version?: string; + commits?: string[]; + eventCommits?: EventCommit[]; + inputs?: Record; + environment?: Record; + headRef?: string; + rejectPush?: boolean; + unrelatedFile?: boolean; +}; + +export type ActionFixture = { + root: string; + workspace: string; + remote: string; + outputFile: string; + branch: string; + triggerSha: string; + result: ReturnType; +}; + +const git = (cwd: string, args: string[]): string => + execFileSync(realGit, args, { cwd, encoding: 'utf8' }).trim(); + +const writeVersion = (workspace: string, version: string): void => { + const [major, minor, patch] = version.split('.'); + fs.writeFileSync( + path.join(workspace, 'version.properties'), + [ + `majorVersion=${major}`, + `minorVersion=${minor}`, + `patchVersion=${patch}`, + 'buildNumber=', + ].join('\n'), + ); +}; + +const createGitShim = (root: string, remote: string): string => { + const shimDirectory = path.join(root, 'bin'); + const shim = path.join(shimDirectory, 'git'); + fs.mkdirSync(shimDirectory); + fs.writeFileSync( + shim, + `#!/bin/sh +if [ "$1" = "push" ]; then + case "$2" in + https://*@github.com/*.git) + if [ "\${E2E_REJECT_PUSH:-}" = "1" ]; then + echo "rejected by e2e git shim" >&2 + exit 1 + fi + shift 2 + exec "${realGit}" push "${remote}" "$@" + ;; + esac +fi +exec "${realGit}" "$@" +`, + { mode: 0o755 }, + ); + + return shimDirectory; +}; + +export const runActionFixture = ( + options: FixtureOptions = {}, +): ActionFixture => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'android-version-bump-')); + const workspace = path.join(root, 'workspace'); + const remote = path.join(root, 'remote.git'); + const home = path.join(root, 'home'); + const outputFile = path.join(root, 'github-output'); + const eventFile = path.join(root, 'event.json'); + const branch = options.headRef ?? 'main'; + + fs.mkdirSync(workspace); + fs.mkdirSync(home); + git(root, ['init', '--bare', '--initial-branch=main', remote]); + git(workspace, ['init', '--initial-branch=main']); + git(workspace, ['config', 'user.name', 'Fixture Author']); + git(workspace, ['config', 'user.email', 'fixture@example.com']); + fs.writeFileSync(path.join(workspace, 'README.md'), 'fixture\n'); + if (options.version) { + writeVersion(workspace, options.version); + } + git(workspace, ['add', '.']); + git(workspace, ['commit', '-m', 'chore: initial fixture']); + git(workspace, ['remote', 'add', 'origin', remote]); + git(workspace, ['push', '-u', 'origin', 'main']); + + if (branch !== 'main') { + git(workspace, ['checkout', '-b', branch]); + } + + for (const [index, message] of (options.commits ?? []).entries()) { + fs.appendFileSync( + path.join(workspace, 'README.md'), + `${index}:${message}\n`, + ); + git(workspace, ['add', 'README.md']); + git(workspace, ['commit', '-m', message]); + } + + if (branch !== 'main' || (options.commits?.length ?? 0) > 0) { + git(workspace, ['push', '-u', 'origin', branch]); + } + const triggerSha = git(workspace, ['rev-parse', 'HEAD']); + + if (branch !== 'main') { + git(workspace, ['checkout', 'main']); + } + if (options.unrelatedFile) { + fs.writeFileSync(path.join(workspace, 'notes.txt'), 'leave me alone\n'); + } + + fs.writeFileSync( + eventFile, + JSON.stringify({ + commits: options.eventCommits ?? options.commits ?? [], + }), + ); + fs.writeFileSync(outputFile, ''); + const shimDirectory = createGitShim(root, remote); + const inputEnvironment = Object.fromEntries( + Object.entries(options.inputs ?? {}).map(([key, value]) => [ + `INPUT_${key.toUpperCase()}`, + value, + ]), + ); + + const result = spawnSync(process.execPath, [actionEntryPoint], { + cwd: workspace, + encoding: 'utf8', + env: { + ...process.env, + ...inputEnvironment, + ...options.environment, + CI: 'true', + HOME: home, + PATH: `${shimDirectory}${path.delimiter}${process.env.PATH}`, + GITHUB_ACTIONS: 'true', + GITHUB_ACTOR: 'fixture-actor', + GITHUB_EVENT_NAME: 'push', + GITHUB_EVENT_PATH: eventFile, + GITHUB_HEAD_REF: options.headRef ?? '', + GITHUB_OUTPUT: outputFile, + GITHUB_REF: `refs/heads/${branch}`, + GITHUB_REPOSITORY: 'fixture/repository', + GITHUB_SHA: triggerSha, + GITHUB_TOKEN: 'fixture-token', + GITHUB_WORKSPACE: workspace, + E2E_REJECT_PUSH: options.rejectPush ? '1' : '', + }, + }); + + return { + root, + workspace, + remote, + outputFile, + branch, + triggerSha, + result, + }; +}; + +export const cleanupFixture = (fixture: ActionFixture): void => { + fs.rmSync(fixture.root, { recursive: true, force: true }); +}; + +export const gitInWorkspace = ( + fixture: ActionFixture, + ...args: string[] +): string => git(fixture.workspace, args); + +export const gitInRemote = ( + fixture: ActionFixture, + ...args: string[] +): string => git(fixture.root, ['--git-dir', fixture.remote, ...args]); + +export const readOutput = (fixture: ActionFixture): string => + fs.readFileSync(fixture.outputFile, 'utf8').trim(); diff --git a/eslint.config.mjs b/eslint.config.mjs index 6a6885c..745debc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,20 +4,14 @@ import tsParser from '@typescript-eslint/parser'; export default [ { - ignores: [ - 'coverage/**', - 'dist/**', - 'e2e/**', - 'lib/**', - 'node_modules/**', - ], + ignores: ['coverage/**', 'dist/**', 'lib/**', 'node_modules/**'], }, { files: ['**/*.ts'], languageOptions: { parser: tsParser, parserOptions: { - project: './tsconfig.json', + project: ['./tsconfig.json', './tsconfig.e2e.json'], sourceType: 'module', }, }, diff --git a/jest.e2e.config.js b/jest.e2e.config.js new file mode 100644 index 0000000..c9e8888 --- /dev/null +++ b/jest.e2e.config.js @@ -0,0 +1,7 @@ +const base = require('./jest.config'); + +module.exports = { + ...base, + testMatch: ['/e2e/**/*.test.ts'], + testTimeout: 30_000, +}; diff --git a/package.json b/package.json index a6c3b76..50911e0 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "package": "ncc build src/main.ts --source-map", "prepare": "husky", "test": "jest", + "test:e2e": "npm run package && jest --config jest.e2e.config.js --runInBand", "test:coverage": "jest --coverage", - "typecheck": "tsc --noEmit --project tsconfig.json", + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.e2e.json", "deps:update": "ncu -u", - "audit:prod": "npm audit --omit=dev --audit-level=high" + "audit:prod": "npm audit --omit=dev --audit-level=high", + "check": "npm run lint && npm run typecheck && npm test -- --runInBand && npm run test:e2e && npm run audit:prod" }, "repository": { "type": "git", diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..fd6b4fd --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["e2e/**/*.ts"] +}