├── .editorconfig ├── .gitattributes ├── .gitignore ├── README.md ├── action.yml ├── compare-dependencies.test.ts ├── compare-dependencies.ts ├── config.subsplit-publish.json ├── dist └── index.js ├── index.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── test-cases └── example-subsplit │ └── composer.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 4 3 | indent_style = space 4 | 5 | [{package.json,.github/**/*.yml}] 6 | indent_size = 2 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /.editorconfig 3 | /config.subsplit-publish.json 4 | /test-cases 5 | /tsconfig.json 6 | /**/*.ts 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /temp 3 | /install.sh 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Use Sub-split Publish Action 2 | 3 | This action publishes a subsplit of the current repository 4 | using the [splitsh-lite](https://github.com/splitsh/lite) tool. 5 | 6 | ## Inputs 7 | 8 | ### `config-path` 9 | 10 | **Required** The path for the sub-split JSON [config](#configuration). 11 | 12 | ### `splitsh-path` 13 | 14 | **Required** The path to install splitsh-lite. You should add this path to the cache. 15 | 16 | ### `source-branch` 17 | 18 | **Required** The source branch to split from. Example: `main` 19 | 20 | ### `origin-remote` 21 | 22 | **Optional** The origin remote name. Default: `origin` 23 | 24 | ### `splitsh-version` 25 | 26 | **Optional** Version of the splitsh-lite binary. Default `v1.0.1` 27 | 28 | ## Configuration 29 | 30 | Sub-splits are configured using a JSON file. The location of the file 31 | should match the path configured at `config-path`. 32 | 33 | Example: 34 | 35 | ```json 36 | { 37 | "sub-splits": [ 38 | { 39 | "name": "workflows", 40 | "directory": ".github/workflows", 41 | "target": "git@github.com:frankdejonge/example-subsplit-publish.git" 42 | } 43 | ] 44 | } 45 | ``` 46 | 47 | Each entry contains 3 keys: 48 | 49 | * `name`: The name of the remote. Must be unique. 50 | * `directory`: The directory to publish to the sub-split. 51 | * `target`: The git URL to publish the sub-split to. 52 | 53 | ## Example Workflow 54 | 55 | ```yaml 56 | on: 57 | push: 58 | branches: 59 | - main 60 | 61 | jobs: 62 | publis_sub_splits: 63 | runs-on: ubuntu-latest 64 | name: Publish Sub-split 65 | steps: 66 | - uses: actions/checkout@v2 67 | with: 68 | fetch-depth: '0' 69 | persist-credentials: 'false' 70 | - uses: frankdejonge/use-github-token@1.0.1 71 | with: 72 | authentication: 'username:${{ secrets.PERSONAL_GITHUB_TOKEN }}' 73 | user_name: 'Your Name' 74 | user_email: 'your@email.com' 75 | - name: Cache splitsh-lite 76 | id: splitsh-cache 77 | uses: actions/cache@v2 78 | with: 79 | path: './.splitsh' 80 | key: '${{ runner.os }}-splitsh-d-101' 81 | - uses: frankdejonge/use-subsplit-publish@1.0.0-beta.6 82 | with: 83 | source-branch: 'main' 84 | config-path: './config.subsplit-publish.json' 85 | splitsh-path: './.splitsh/splitsh-lite' 86 | splitsh-version: 'v1.0.1' 87 | ``` 88 | 89 | ## Tag Management 90 | 91 | This action propagated tags when it's configured in the action. 92 | 93 | ```yaml 94 | on: 95 | create: 96 | tags: 97 | - '*' 98 | delete: 99 | tags: 100 | - '*' 101 | ``` 102 | 103 | For each sub-split, the tags are propagated when the target commit hash has no prior tag. 104 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Sub-split Publish' 2 | description: 'Publish git repository sub-splits using splish-lite' 3 | 4 | inputs: 5 | config-path: 6 | required: true 7 | description: 'Path for sub-split config' 8 | splitsh-version: 9 | default: v1.0.1 10 | description: 'Version of splitsh to install' 11 | required: false 12 | splitsh-path: 13 | description: 'Path to install splitsh-lite' 14 | required: true 15 | origin-remote: 16 | description: 'Name of the origin remote' 17 | default: 'origin' 18 | required: false 19 | max-tries: 20 | description: 'Amount of retries per call that can fail (splish/git)' 21 | default: '5' 22 | required: false 23 | source-branch: 24 | description: 'Branch to split from' 25 | required: true 26 | branding: 27 | icon: external-link 28 | color: purple 29 | runs: 30 | using: 'node20' 31 | main: 'dist/index.js' 32 | -------------------------------------------------------------------------------- /compare-dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { verifyDependencies } from './compare-dependencies'; 2 | import * as path from 'path'; 3 | 4 | let exampleSubSplitPath = path.join(__dirname, 'test-cases/example-subsplit'); 5 | let dependencyPaths = [exampleSubSplitPath]; 6 | 7 | describe('compare-dependencies', () => { 8 | it('should detect invalid dependencies', async function () { 9 | await expect(verifyDependencies(dependencyPaths, {"some/dependency": "1.0.0"})) 10 | .rejects.toEqual(new Error(`Split located at "${exampleSubSplitPath}" has a dependency "some/dependency" that does not match version "1.0.0"`)); 11 | }); 12 | 13 | it('should pass valid dependencies', async function () { 14 | await expect(verifyDependencies(dependencyPaths, {"some/dependency": "2.0.0"})).resolves.toBe(undefined); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /compare-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as semver from 'semver'; 3 | 4 | interface DependencyExtractor { 5 | (path: string): Promise 6 | } 7 | 8 | interface DependencyMap { 9 | [index: string]: string 10 | } 11 | 12 | interface ComposerJson { 13 | require: DependencyMap, 14 | 'require-dev': DependencyMap, 15 | } 16 | 17 | export const extractDependencies: DependencyExtractor = async (path: string): Promise => { 18 | if ( ! path.endsWith('composer.json')) { 19 | path = path + "/composer.json"; 20 | } 21 | 22 | let fileContents = await fs.readFile(path); 23 | let composerPayload = JSON.parse(fileContents.toString()) as ComposerJson; 24 | 25 | return { 26 | ...composerPayload['require'], 27 | ...composerPayload['require-dev'], 28 | }; 29 | }; 30 | 31 | interface ComparisonOptions { 32 | releaseVersion: string, 33 | } 34 | 35 | async function verifyDependency(splitDirectory: string, satisfyDependencies: DependencyMap) { 36 | let dependencies = await extractDependencies(splitDirectory); 37 | 38 | Object.entries(satisfyDependencies).forEach(([pkg, version]) => { 39 | if ( ! (pkg in dependencies)) { 40 | return; 41 | } 42 | 43 | if (semver.satisfies(version, dependencies[pkg]) === false) { 44 | throw new Error(`Split located at "${splitDirectory}" has a dependency "${pkg}" that does not match version "${version}"`); 45 | } 46 | }); 47 | } 48 | 49 | export async function verifyDependencies(splitDirectories: string[], satisfyDependencies: {[index: string]: string}) { 50 | await Promise.all(splitDirectories.map(splitDirectory => verifyDependency(splitDirectory, satisfyDependencies))); 51 | } 52 | -------------------------------------------------------------------------------- /config.subsplit-publish.json: -------------------------------------------------------------------------------- 1 | { 2 | "sub-splits": [ 3 | { 4 | "name": "workflows", 5 | "directory": ".github/workflows", 6 | "target": "git@github.com:frankdejonge/example-subsplit-publish.git" 7 | }, 8 | { 9 | "name": "test-cases", 10 | "directory": "test-cases", 11 | "target": "git@github.com:frankdejonge/another-example-subsplit-publish.git", 12 | "target-branch": "test-cases-branch" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as github from '@actions/github'; 3 | import {exec, ExecOptions} from '@actions/exec'; 4 | import {CreateEvent, DeleteEvent, PushEvent} from '@octokit/webhooks-types' 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | import {verifyDependencies} from './compare-dependencies'; 8 | 9 | interface subsplit { 10 | name: string, 11 | directory: string, 12 | target: string, 13 | 'target-branch'?: string, 14 | } 15 | 16 | type subsplits = subsplit[]; 17 | 18 | type configurationOptions = { 19 | 'sub-splits': subsplits, 20 | 'dependencies-must-satisfy'?: { 21 | [index: string]: string, 22 | } 23 | } 24 | 25 | function ensureDirExists(path): void { 26 | try { 27 | fs.mkdirSync(path); 28 | } catch (err) { 29 | if (err.code !== 'EEXIST') { 30 | throw err; 31 | } 32 | } 33 | } 34 | 35 | function ensureDirIsRemoved(path) { 36 | try { 37 | fs.rmdirSync(path, {recursive: true}); 38 | } catch (err) { 39 | if (err.code !== 'ENOENT') { 40 | throw err; 41 | } 42 | } 43 | } 44 | 45 | function ensureFileIsRemoved(path) { 46 | try { 47 | fs.unlinkSync(path); 48 | } catch (err) { 49 | if (err.code !== 'ENOENT') { 50 | throw err; 51 | } 52 | } 53 | } 54 | 55 | async function downloadSplitsh(splitshPath, splitshVersion): Promise { 56 | let splitshDir = path.dirname(splitshPath); 57 | ensureDirExists(splitshDir); 58 | ensureFileIsRemoved(splitshPath); 59 | ensureDirIsRemoved('/tmp/splitsh-download/'); 60 | fs.mkdirSync('/tmp/splitsh-download/'); 61 | let downloadDir = '/tmp/splitsh-download/'; 62 | let downloadPath = `${downloadDir}split-lite.tar.gz`; 63 | let platform = process.platform === 'darwin' ? 'lite_darwin_amd64' : 'lite_linux_amd64'; 64 | console.log(`downloading variant ${platform}`); 65 | let url = `https://github.com/splitsh/lite/releases/download/${splitshVersion}/${platform}.tar.gz`; 66 | await exec(`wget -O ${downloadPath} ${url}`); 67 | await exec(`tar -zxpf ${downloadPath} --directory ${downloadDir}`); 68 | await exec(`chmod +x ${downloadDir}splitsh-lite`); 69 | await exec(`mv ${downloadDir}splitsh-lite ${splitshPath}`); 70 | ensureDirIsRemoved(downloadDir); 71 | } 72 | 73 | async function ensureRemoteExists(name, target): Promise { 74 | try { 75 | await exec('git', ['remote', 'add', name, target]); 76 | } catch (e) { 77 | if (!e.message.match(/failed with exit code 3$/g)) { 78 | throw e; 79 | } 80 | } 81 | } 82 | 83 | async function captureExecOutput(command: string, args: string[], options?: ExecOptions): Promise { 84 | let output = ''; 85 | options = options || {}; 86 | await exec(command, args, { 87 | listeners: { 88 | stdout: (data: Buffer) => { 89 | output += data.toString(); 90 | } 91 | }, 92 | ...options 93 | }); 94 | 95 | return output.trim(); 96 | } 97 | 98 | async function publishSubSplit(binary, origin, originBranch, target, branch, name, directory): Promise { 99 | let hash = await captureExecOutput(binary, [`--prefix=${directory}`, `--origin=${origin}/${originBranch}`]); 100 | console.log(name, directory, hash); 101 | await exec('git', ['push', target, `${hash.trim()}:refs/heads/${branch}`, '-f']); 102 | } 103 | 104 | async function tagExists(tag: string, directory: string): Promise { 105 | try { 106 | let code = await exec('git', ['show-ref', '--tags', '--quiet', '--verify', '--', `refs/tags/${tag}`], {cwd: directory}); 107 | 108 | return code === 0; 109 | } catch (err) { 110 | return false; 111 | } 112 | } 113 | 114 | async function commitHashHasTag(hash: string, clonePath: string) { 115 | let output = await captureExecOutput('git', ['tag', '--points-at', hash], {cwd: clonePath}); 116 | console.log(hash, 'points-at', output); 117 | 118 | return output === ''; 119 | } 120 | 121 | const withRetries = (max: number) => async (fn: () => Promise): Promise => { 122 | let tries = 0; 123 | 124 | while (true) { 125 | tries++; 126 | 127 | try { 128 | return await fn(); 129 | } catch (e) { 130 | if (tries >= max) { 131 | throw e; 132 | } 133 | 134 | await new Promise(resolve => setTimeout(resolve, 500)); 135 | } 136 | } 137 | } 138 | 139 | (async () => { 140 | const context = github.context; 141 | const configPath = core.getInput('config-path'); 142 | const splitshPath = path.resolve(process.cwd(), core.getInput('splitsh-path')); 143 | const splitshVersion = core.getInput('splitsh-version'); 144 | const origin = core.getInput('origin-remote'); 145 | const branch = core.getInput('source-branch'); 146 | const retry = withRetries(Number(core.getInput('max-tries'))); 147 | 148 | if (!fs.existsSync(splitshPath)) { 149 | await downloadSplitsh(splitshPath, splitshVersion); 150 | } 151 | 152 | let configOptions = JSON.parse(fs.readFileSync(configPath).toString()) as configurationOptions; 153 | let subSplits = configOptions['sub-splits']; 154 | console.table(subSplits); 155 | 156 | if (context.eventName === "push") { 157 | // let event = context.payload as PushEvent; 158 | 159 | if (configOptions.hasOwnProperty('dependencies-must-satisfy') && configOptions['dependencies-must-satisfy']) { 160 | await verifyDependencies(subSplits.map(s => s.directory), configOptions['dependencies-must-satisfy']); 161 | } 162 | 163 | for (let split of subSplits) { 164 | await retry(async () => { 165 | await ensureRemoteExists(split.name, split.target); 166 | }); 167 | 168 | await retry(async () => { 169 | await publishSubSplit(splitshPath, origin, branch, split.name, split['target-branch'] || branch, split.name, split.directory); 170 | }); 171 | } 172 | } else if (context.eventName === "create") { 173 | let event = context.payload as CreateEvent; 174 | let tag = event.ref; 175 | 176 | if (event.ref_type !== "tag") { 177 | return; 178 | } 179 | 180 | for (let split of subSplits) { 181 | let hash = undefined; 182 | await retry(async () => { 183 | hash = await captureExecOutput(splitshPath, [`--prefix=${split.directory}`, `--origin=tags/${tag}`]); 184 | }); 185 | console.log('hash from commit hash origin', hash); 186 | let clonePath = `./.repos/${split.name}/`; 187 | fs.mkdirSync(clonePath, {recursive: true}); 188 | 189 | await retry(async () => { 190 | await exec('git', ['clone', split.target, '.'], {cwd: clonePath}); 191 | }); 192 | 193 | if (await commitHashHasTag(hash, clonePath)) { 194 | await retry(async () => { 195 | await exec('git', ['tag', '-a', tag, hash, '-m', `"Tag: ${tag}"`], {cwd: clonePath}); 196 | }); 197 | await retry(async () => { 198 | await exec('git', ['push', '--tags'], {cwd: clonePath}); 199 | }); 200 | } 201 | } 202 | } else if (context.eventName === "delete") { 203 | let event = context.payload as DeleteEvent; 204 | let tag = event.ref; 205 | 206 | if (event.ref_type !== "tag") { 207 | return; 208 | } 209 | 210 | for (let split of subSplits) { 211 | let clonePath = `./.repos/${split.name}/`; 212 | fs.mkdirSync(clonePath, {recursive: true}); 213 | 214 | await retry(async () => { 215 | await exec('git', ['clone', split.target, '.'], {cwd: clonePath}); 216 | }); 217 | 218 | if (await tagExists(tag, clonePath)) { 219 | await retry(async () => { 220 | await exec('git', ['push', '--delete', origin, tag], {cwd: clonePath}); 221 | }); 222 | } 223 | } 224 | } 225 | })().catch(error => { 226 | core.setFailed(error); 227 | }); 228 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverage: false, 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "engines": { 4 | "node": "16" 5 | }, 6 | "scripts": { 7 | "package": "ncc build index.ts -C -o dist --minify", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "^1.10.1", 12 | "@actions/exec": "^1.1.1", 13 | "@actions/github": "^6.0.0", 14 | "@octokit/webhooks": "^13.1.1" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^29.5.12", 18 | "@types/node": "^20.12.2", 19 | "@types/semver": "^7.5.8", 20 | "@vercel/ncc": "^0.38.1", 21 | "jest": "^29.7.0", 22 | "semver": "^7.6.0", 23 | "ts-jest": "^29.1.2", 24 | "typescript": "^5.4.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test-cases/example-subsplit/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "some/dependency": "^2.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "moduleResolution": "node" 5 | } 6 | } 7 | --------------------------------------------------------------------------------