├── action ├── test │ ├── jest-global-setup-hooks.ts │ ├── bin │ │ ├── post-run-clean.sh │ │ ├── generate-known-hosts.sh │ │ ├── port-forward.js │ │ └── run-tests.sh │ ├── specs │ │ ├── __snapshots__ │ │ │ ├── ssh-custom-username-email.spec.ts.snap │ │ │ ├── ssh-no-branch.spec.ts.snap │ │ │ ├── ssh-no-branch-squash.spec.ts.snap │ │ │ ├── ssh-existing-branch-squash.spec.ts.snap │ │ │ ├── ssh-existing-branch.spec.ts.snap │ │ │ ├── ssh-target-dir-exists.spec.ts.snap │ │ │ ├── ssh-existing-branch-folder-space.spec.ts.snap │ │ │ ├── ssh-target-dir-no-exists.spec.ts.snap │ │ │ ├── ssh-existing-branch-custom-rm-globs.spec.ts.snap │ │ │ ├── ssh-skip-empty-commits.spec.ts.snap │ │ │ ├── ssh-custom-messages.spec.ts.snap │ │ │ ├── ssh-no-branch-custom-pusher.spec.ts.snap │ │ │ └── ssh-custom-tags.spec.ts.snap │ │ ├── ssh-github.spec.ts │ │ ├── ssh-no-branch.spec.ts │ │ ├── ssh-no-branch-squash.spec.ts │ │ ├── ssh-custom-username-email.spec.ts │ │ ├── self.spec.ts │ │ ├── ssh-existing-branch.spec.ts │ │ ├── ssh-custom-messages.spec.ts │ │ ├── ssh-existing-branch-folder-space.spec.ts │ │ ├── ssh-skip-empty-commits.spec.ts │ │ ├── ssh-existing-branch-squash.spec.ts │ │ ├── ssh-target-dir-no-exists.spec.ts │ │ ├── ssh-custom-tags.spec.ts │ │ ├── ssh-existing-branch-custom-rm-globs.spec.ts │ │ ├── ssh-target-dir-exists.spec.ts │ │ ├── ssh-no-branch-custom-pusher.spec.ts │ │ └── misconfiguration.spec.ts │ ├── jest-global-setup.ts │ ├── jest.config.js │ ├── docker │ │ └── git-ssh │ │ │ └── Dockerfile │ ├── docker-compose.yml │ ├── util │ │ ├── git.ts │ │ └── io.ts │ └── util.ts ├── prettier.config.js ├── .gitignore ├── src │ ├── run.ts │ └── index.ts ├── resources │ └── known_hosts_github.com ├── package.json └── tsconfig.json ├── .github ├── FUNDING.yml └── workflows │ ├── copy-to-master.yml │ ├── ci-pr.yml │ ├── badges.yml │ ├── cron.yml │ └── ci.yml ├── action.yml ├── LICENSE └── README.md /action/test/jest-global-setup-hooks.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000 * 60 * 60); -------------------------------------------------------------------------------- /action/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /action/test/bin/post-run-clean.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | rm -f ~/.ssh/known_hosts -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: s0 4 | -------------------------------------------------------------------------------- /action/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /.nyc_output 4 | /test/data 5 | /coverage.lcov 6 | /.env 7 | /coverage -------------------------------------------------------------------------------- /action/test/bin/generate-known-hosts.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | ssh-keyscan git-ssh > /home/node/repo/action/test/data/known_hosts -------------------------------------------------------------------------------- /action/src/run.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file - this file is used purely as an entry-point */ 2 | 3 | import { main } from './'; 4 | 5 | main({ 6 | log: console, 7 | env: process.env, 8 | }).catch((err) => { 9 | console.error(err); 10 | process.exit(1); 11 | }); 12 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-custom-username-email.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test custom username and email 1`] = ` 4 | "msg:Update branch-a to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:tester " 7 | `; 8 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-no-branch.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a new branch over ssh 1`] = ` 4 | "msg:Update branch-a to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:s0 " 7 | `; 8 | -------------------------------------------------------------------------------- /action/test/jest-global-setup.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import * as util from './util'; 4 | 5 | export = async () => { 6 | 7 | // Generate known-hosts 8 | await util.exec( 9 | path.join(util.TEST_DIR, 'bin/generate-known-hosts.sh'), 10 | { cwd: util.DATA_DIR } 11 | ); 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-no-branch-squash.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a new branch over ssh, and squash commits 1`] = ` 4 | "msg:Update branch-a to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:s0 " 7 | `; 8 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # action.yml 2 | name: 'Push git subdirectory as branch' 3 | description: 'Push a subdirectory as a branch to any git repo over SSH (or to the local repo)' 4 | author: 'Sam Lanning ' 5 | runs: 6 | using: 'node20' 7 | main: 'action/dist/index.js' 8 | branding: 9 | icon: 'upload-cloud' 10 | color: 'purple' 11 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-existing-branch-squash.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a existing branch over ssh, and squash commits 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:s0 " 7 | `; 8 | -------------------------------------------------------------------------------- /action/test/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | rootDir: '../', 5 | globalSetup: '/test/jest-global-setup.ts', 6 | testMatch: [ 7 | '/test/**/*.spec.ts', 8 | ], 9 | collectCoverageFrom: [ 10 | '/src/**/*.ts' 11 | ], 12 | setupFilesAfterEnv: [ 13 | '/test/jest-global-setup-hooks.ts' 14 | ], 15 | }; -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-existing-branch.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a existing branch over ssh 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:s0 7 | msg:initial 8 | tree:c1cde76c408d7c8cb6956f35f1e4853366340aec 9 | author:Test User " 10 | `; 11 | -------------------------------------------------------------------------------- /action/test/docker/git-ssh/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jkarlos/git-server-docker:latest 2 | 3 | ARG GIT_UID 4 | 5 | RUN apk update 6 | RUN apk --no-cache add shadow \ 7 | --allow-untrusted \ 8 | --repository http://dl-cdn.alpinelinux.org/alpine/v3.15/community/ \ 9 | --repository http://dl-cdn.alpinelinux.org/alpine/v3.15/main/ 10 | 11 | # We want to allow for the UID of the git user to match the repos 12 | RUN usermod -u $GIT_UID git 13 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-target-dir-exists.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a branch on a custom dir that exists 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:91548ed23fe65d56dfcbb58357a11c32c9b0b95d 6 | author:s0 7 | msg:initial 8 | tree:6ceb2d248e9b9c62291e9d1a5c4afeca8a49b2c8 9 | author:Test User " 10 | `; 11 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-existing-branch-folder-space.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Spaces in folder names are correctly handled 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:3f97adec519b4ce0c5950da8eb91bd2939d20689 6 | author:s0 7 | msg:initial 8 | tree:c1cde76c408d7c8cb6956f35f1e4853366340aec 9 | author:Test User " 10 | `; 11 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-target-dir-no-exists.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Deploy to a branch on a custom dir that does not exist 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:91548ed23fe65d56dfcbb58357a11c32c9b0b95d 6 | author:s0 7 | msg:initial 8 | tree:2ba0f344a4227360745f8b743b28af58d010c2f4 9 | author:Test User " 10 | `; 11 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-existing-branch-custom-rm-globs.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Check that only target deleted files are removed 1`] = ` 4 | "msg:Update master to output generated at 5 | tree:a410d7d48ed702489e7ff5f4de76163f3b4d48d6 6 | author:s0 7 | msg:initial 8 | tree:2ba0f344a4227360745f8b743b28af58d010c2f4 9 | author:Test User " 10 | `; 11 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-skip-empty-commits.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Skip empty commits 1`] = ` 4 | "msg:Update branch-a to output generated at 5 | 6 | tree:b60735c9b4d9728b47c0f2752bb973cd6f3d9d80 7 | author:s0 8 | msg:Update branch-a to output generated at 9 | 10 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 11 | author:s0 " 12 | `; 13 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-custom-messages.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test custom message templates 1`] = ` 4 | "msg:This is another commit follow up with no content changes 5 | 6 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 7 | author:s0 8 | msg:This is a test message with placeholders: 9 | * 10 | * 11 | * {branch} 12 | 13 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 14 | author:s0 " 15 | `; 16 | -------------------------------------------------------------------------------- /.github/workflows/copy-to-master.yml: -------------------------------------------------------------------------------- 1 | # As some repositories are using this action by referring to @master 2 | # this branch needs to be kept alive until these references no longer exist. 3 | 4 | name: Push develop to master 5 | on: 6 | push: 7 | branches: develop 8 | 9 | jobs: 10 | push-to-master: 11 | name: Push to Master 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@master 15 | - run: | 16 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git 17 | git push -f origin develop:master -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-no-branch-custom-pusher.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Pusher (invalid) 1`] = ` 4 | "msg:Update branch-a to output generated at 5 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 6 | author:s0 " 7 | `; 8 | 9 | exports[`Custom Pusher 1`] = ` 10 | "msg:Update branch-a to output generated at 11 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 12 | author:Alice Bob " 13 | `; 14 | 15 | exports[`No Pusher or Actor 1`] = ` 16 | "msg:Update branch-a to output generated at 17 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 18 | author:Git Publish Subdirectory " 19 | `; 20 | -------------------------------------------------------------------------------- /action/test/specs/__snapshots__/ssh-custom-tags.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test custom tags 1`] = ` 4 | "msg:This is another commit follow up with no content changes 5 | 6 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 7 | author:s0 8 | msg:Update branch-a to output generated at 9 | 10 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 11 | author:s0 " 12 | `; 13 | 14 | exports[`Test custom tags 2`] = ` 15 | "msg:This is another commit follow up with no content changes 16 | 17 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 18 | author:s0 19 | msg:Update branch-a to output generated at 20 | 21 | tree:8bf87c66655949e66937b11593cc4ae732d1f610 22 | author:s0 " 23 | `; 24 | -------------------------------------------------------------------------------- /action/test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | test-node: 4 | image: "node:12" 5 | user: "node" 6 | working_dir: /home/node/repo/action 7 | volumes: 8 | - ../../:/home/node/repo 9 | command: "./test/bin/port-forward.js" 10 | container_name: test-node 11 | networks: 12 | test: 13 | aliases: 14 | - node 15 | expose: 16 | - "9230" 17 | ports: 18 | - "127.0.0.1:9229:9230" 19 | test-git-ssh: 20 | image: git-ssh-server 21 | volumes: 22 | - ./data/server-ssh-keys:/git-server/keys 23 | - ./data/repos:/git-server/repos 24 | expose: 25 | - "22" 26 | networks: 27 | test: 28 | aliases: 29 | - git-ssh 30 | ports: 31 | - "127.0.0.1:2222:22" 32 | 33 | networks: 34 | test: 35 | name: test-network 36 | -------------------------------------------------------------------------------- /action/resources/known_hosts_github.com: -------------------------------------------------------------------------------- 1 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= 2 | github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= 3 | github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl 4 | -------------------------------------------------------------------------------- /action/test/bin/port-forward.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | /* 4 | * A script that forwards port 2930 to 2992 to allow for attaching a debugger 5 | * from outside of the docker image. 6 | */ 7 | 8 | const net = require('net'); 9 | 10 | net.createServer(from => { 11 | console.log('New Connection'); 12 | try { 13 | const to = net.createConnection({ 14 | host: 'localhost', 15 | port: 9229 16 | }); 17 | const close = () => { 18 | to.destroy(); 19 | from.destroy(); 20 | } 21 | from.pipe(to); 22 | to.pipe(from); 23 | to.on('close', close); 24 | to.on('error', close); 25 | to.on('end', close); 26 | from.on('close', close); 27 | from.on('error', close); 28 | from.on('end', close); 29 | } catch { 30 | console.log('Unable to connect'); 31 | from.destroy(); 32 | } 33 | }).listen(9230); -------------------------------------------------------------------------------- /action/test/util/git.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import git, { TreeEntry } from 'isomorphic-git'; 3 | 4 | export const listTree = async (dir: string) => { 5 | const head = await git.resolveRef({ 6 | fs, 7 | gitdir: dir, 8 | ref: 'refs/heads/master', 9 | }); 10 | const currentCommit = await git.readCommit({ 11 | fs, 12 | gitdir: dir, 13 | oid: head, 14 | }); 15 | const tree: { 16 | /** 17 | * TODO: fix this, allow access to file path without impl detail 18 | * 19 | * @deprecated 20 | */ 21 | _fullpath: string; 22 | }[] = await git.walk({ 23 | fs, 24 | gitdir: dir, 25 | trees: [git.TREE({ ref: currentCommit.oid })], 26 | reduce: async (parent: unknown[], children: unknown[]) => 27 | [parent, ...children].flat(), 28 | }); 29 | return tree.map((s) => s._fullpath); 30 | }; 31 | -------------------------------------------------------------------------------- /action/test/bin/run-tests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -xe 4 | 5 | 6 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 7 | 8 | cd $SCRIPT_DIR 9 | cd ../ 10 | 11 | 12 | echo :: Preparing test data directory; 13 | rm -rf ../.nyc_output 14 | rm -rf data 15 | mkdir data 16 | mkdir data/server-ssh-keys 17 | mkdir data/repos 18 | 19 | echo :: Generating SSH Keys 20 | ssh-keygen -t ed25519 -N "" -f data/id 21 | ssh-keygen -t ed25519 -N "" -f data/id2 22 | cp data/id.pub data/server-ssh-keys/id.pub 23 | 24 | docker build --build-arg GIT_UID=$(id -u) -t git-ssh-server docker/git-ssh 25 | 26 | docker-compose up -d --force-recreate 27 | 28 | echo ':: Change UID of node user' 29 | docker exec -u root -i test-node usermod -u $(id -u) node 30 | 31 | echo ':: Running Tests' 32 | exec docker exec -u $(id -u) -i test-node npm run test-init -- "$@" 33 | -------------------------------------------------------------------------------- /action/test/util/io.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { mkdirP, rmRF } from '@actions/io'; 3 | 4 | import * as util from '../util'; 5 | 6 | export const prepareTestFolders = async (args: { __filename: string }) => { 7 | const testName = path.basename(__filename); 8 | const repoName = `${testName}.git`; 9 | const repoUrl = `ssh://git@git-ssh/git-server/repos/${repoName}`; 10 | 11 | const repoDir = path.join(util.REPOS_DIR, repoName); 12 | const workDir = path.join(util.DATA_DIR, __filename); 13 | const repoCloneDir = path.join(workDir, 'clone'); 14 | const dataDir = path.join(workDir, 'data'); 15 | 16 | await rmRF(repoDir); 17 | await rmRF(workDir); 18 | await mkdirP(repoDir); 19 | await mkdirP(workDir); 20 | await mkdirP(dataDir); 21 | 22 | return { 23 | testName, 24 | repoName, 25 | repoUrl, 26 | repoDir, 27 | workDir, 28 | repoCloneDir, 29 | dataDir, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /action/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-publish-subdir-action", 3 | "scripts": { 4 | "build": "tsc && ncc build lib/src/run", 5 | "lint-prettier": "prettier -c src/**/* test/**/*.ts", 6 | "lint": "npm run lint-prettier", 7 | "lint:fix": "prettier -w src/**/* test/**/*.ts", 8 | "start": "node lib", 9 | "test-init": "jest --projects test/jest.config.js --runInBand --verbose", 10 | "test-run": "nyc ts-node --transpile-only src", 11 | "test": "./test/bin/run-tests.sh --colors" 12 | }, 13 | "devDependencies": { 14 | "@types/git-url-parse": "^9.0.1", 15 | "@types/jest": "^27.4.0", 16 | "@types/node": "^17.0.16", 17 | "@vercel/ncc": "^0.38.1", 18 | "dotenv": "^16.0.0", 19 | "git-url-parse": "^13.1.0", 20 | "jest": "^27.5.1", 21 | "prettier": "^2.3.0", 22 | "ts-jest": "^27.1.3", 23 | "ts-node": "^10.5.0", 24 | "typescript": "^4.5.5" 25 | }, 26 | "dependencies": { 27 | "@actions/io": "^1.1.1", 28 | "fast-glob": "^3.2.11", 29 | "isomorphic-git": "^1.11.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sam Lanning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /action/test/specs/ssh-github.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | const RUNNING_IN_GITHUB = !!process.env.GITHUB_SSH_PRIVATE_KEY; 9 | 10 | /** 11 | * Unit test to only run in GitHub environment 12 | */ 13 | const itGithubOnly = RUNNING_IN_GITHUB ? it : xit; 14 | 15 | itGithubOnly('Deploy to an existing branch on GitHub', async () => { 16 | const folders = await prepareTestFolders({ __filename }); 17 | 18 | // Create empty repo 19 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 20 | 21 | // Create dummy data 22 | await mkdirP(path.join(folders.dataDir, 'dummy')); 23 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 24 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 25 | 26 | // Run Action 27 | await util.runWithGithubEnv( 28 | path.basename(__filename), 29 | { 30 | REPO: folders.repoUrl, 31 | BRANCH: 'branch-a', 32 | FOLDER: folders.dataDir, 33 | SSH_PRIVATE_KEY: util.getGitHubSSHPrivateKey(), 34 | }, 35 | 's0/test', 36 | {}, 37 | 's0' 38 | ); 39 | 40 | // Check that the log of the repo is as expected 41 | // TODO: clone the repo from GitHub and ensure it looks correct 42 | // For now, the job succeeding is good enough 43 | }); 44 | -------------------------------------------------------------------------------- /action/test/specs/ssh-no-branch.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Deploy to a new branch over ssh', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Create dummy data 15 | await mkdirP(path.join(folders.dataDir, 'dummy')); 16 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 17 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 18 | 19 | // Run Action 20 | await util.runWithGithubEnv( 21 | path.basename(__filename), 22 | { 23 | REPO: folders.repoUrl, 24 | BRANCH: 'branch-a', 25 | FOLDER: folders.dataDir, 26 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 27 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 28 | }, 29 | 's0/test', 30 | {}, 31 | 's0' 32 | ); 33 | 34 | // Check that the log of the repo is as expected 35 | // (check tree-hash, commit message, and author) 36 | const log = ( 37 | await util.exec( 38 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 39 | { 40 | cwd: folders.repoDir, 41 | } 42 | ) 43 | ).stdout; 44 | const sha = await util.getRepoSha(); 45 | const cleanedLog = log.replace(sha, ''); 46 | expect(cleanedLog).toMatchSnapshot(); 47 | }); 48 | -------------------------------------------------------------------------------- /action/test/specs/ssh-no-branch-squash.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Deploy to a new branch over ssh, and squash commits', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Create dummy data 15 | await mkdirP(path.join(folders.dataDir, 'dummy')); 16 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 17 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 18 | 19 | // Run Action 20 | await util.runWithGithubEnv( 21 | path.basename(__filename), 22 | { 23 | REPO: folders.repoUrl, 24 | BRANCH: 'branch-a', 25 | FOLDER: folders.dataDir, 26 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 27 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 28 | SQUASH_HISTORY: 'true', 29 | }, 30 | 's0/test', 31 | {}, 32 | 's0' 33 | ); 34 | 35 | // Check that the log of the repo is as expected 36 | // (check tree-hash, commit message, and author) 37 | const log = ( 38 | await util.exec( 39 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 40 | { 41 | cwd: folders.repoDir, 42 | } 43 | ) 44 | ).stdout; 45 | const sha = await util.getRepoSha(); 46 | const cleanedLog = log.replace(sha, ''); 47 | expect(cleanedLog).toMatchSnapshot(); 48 | }); 49 | -------------------------------------------------------------------------------- /action/test/specs/ssh-custom-username-email.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Test custom username and email', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Create dummy data 15 | await mkdirP(path.join(folders.dataDir, 'dummy')); 16 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 17 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 18 | 19 | // Run Action 20 | await util.runWithGithubEnv( 21 | path.basename(__filename), 22 | { 23 | REPO: folders.repoUrl, 24 | BRANCH: 'branch-a', 25 | FOLDER: folders.dataDir, 26 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 27 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 28 | COMMIT_NAME: 'tester', 29 | COMMIT_EMAIL: 'tester@test.com', 30 | }, 31 | 's0/test', 32 | {}, 33 | 's0' 34 | ); 35 | 36 | // Check that the log of the repo is as expected 37 | // (check tree-hash, commit message, and author) 38 | const log = ( 39 | await util.exec( 40 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 41 | { 42 | cwd: folders.repoDir, 43 | } 44 | ) 45 | ).stdout; 46 | const sha = await util.getRepoSha(); 47 | const cleanedLog = log.replace(sha, ''); 48 | expect(cleanedLog).toMatchSnapshot(); 49 | }); 50 | -------------------------------------------------------------------------------- /action/test/specs/self.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | const RUNNING_IN_GITHUB = 9 | !!process.env.GITHUB_SELF_TEST_REPO && !!process.env.GITHUB_SELF_TEST_TOKEN; 10 | 11 | /** 12 | * Unit test to only run in GitHub environment 13 | */ 14 | const itGithubOnly = RUNNING_IN_GITHUB ? it : xit; 15 | 16 | itGithubOnly('Deploy to another branch on self repo', async () => { 17 | const repo = process.env.GITHUB_SELF_TEST_REPO; 18 | if (!repo) 19 | throw new Error( 20 | 'Environment variable GITHUB_SELF_TEST_REPO not set, needed for tests' 21 | ); 22 | 23 | const token = process.env.GITHUB_SELF_TEST_TOKEN; 24 | if (!token) 25 | throw new Error( 26 | 'Environment variable GITHUB_SELF_TEST_TOKEN not set, needed for tests' 27 | ); 28 | 29 | // Create dummy data 30 | const folders = await prepareTestFolders({ __filename }); 31 | await mkdirP(path.join(folders.dataDir, 'dummy')); 32 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 33 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 34 | 35 | // Run Action 36 | await util.runWithGithubEnv( 37 | path.basename(__filename), 38 | { 39 | REPO: 'self', 40 | BRANCH: 'tmp-test-branch', 41 | FOLDER: folders.dataDir, 42 | GITHUB_TOKEN: token, 43 | }, 44 | repo, 45 | {}, 46 | 's0' 47 | ); 48 | 49 | // Check that the log of the repo is as expected 50 | // (check tree-hash, commit message, and author) 51 | // TODO 52 | }); 53 | -------------------------------------------------------------------------------- /.github/workflows/ci-pr.yml: -------------------------------------------------------------------------------- 1 | name: CI Checks 2 | on: pull_request 3 | 4 | jobs: 5 | ci: 6 | name: Run Build and check output is checked-in 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Use Node.js 11 | uses: actions/setup-node@main 12 | with: 13 | node-version: 20.x 14 | - name: 'Build' 15 | run: | 16 | cd action 17 | npm install 18 | npm run build 19 | - name: Check no files have changes 20 | run: git diff --exit-code 21 | unit-tests: 22 | name: Run Unit Tests 23 | runs-on: ubuntu-latest 24 | # do not run from forks, as forks don’t have access to repository secrets 25 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 26 | steps: 27 | - name: Install docker-compose 28 | run: sudo apt-get update && sudo apt-get install -y docker-compose 29 | - uses: actions/checkout@master 30 | - name: Use Node.js 31 | uses: actions/setup-node@main 32 | with: 33 | node-version: 20.x 34 | - name: Install NPM Packages 35 | run: | 36 | cd action 37 | npm install 38 | - name: Run Unit Tests 39 | run: | 40 | cd action 41 | npm run test -- --coverage 42 | env: 43 | GITHUB_SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 44 | GITHUB_SELF_TEST_REPO: ${{ github.repository }} 45 | GITHUB_SELF_TEST_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Check Linting 47 | run: | 48 | cd action 49 | npm run lint 50 | - name: Submit to CodeCov 51 | uses: codecov/codecov-action@main 52 | with: 53 | file: ./action/coverage/lcov.info 54 | fail_ci_if_error: false 55 | -------------------------------------------------------------------------------- /.github/workflows/badges.yml: -------------------------------------------------------------------------------- 1 | name: Generate Badges 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | get-badges: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Use Node.js 15 | uses: actions/setup-node@main 16 | with: 17 | node-version: 20.x 18 | - run: | 19 | cd action 20 | npm install 21 | - id: libyear 22 | uses: s0/libyear-node-action@v0.1.1 23 | env: 24 | FOLDER: action 25 | - run: mkdir badges 26 | - uses: emibcn/badge-action@master 27 | with: 28 | label: 'libyear drift' 29 | status: ${{ steps.libyear.outputs.drift }} year(s) behind 30 | color: 'blue' 31 | path: 'badges/drift.svg' 32 | - uses: emibcn/badge-action@master 33 | with: 34 | label: 'libyear pulse' 35 | status: ${{ steps.libyear.outputs.pulse }} year(s) behind 36 | color: 'blue' 37 | path: 'badges/pulse.svg' 38 | - uses: emibcn/badge-action@master 39 | with: 40 | label: 'libyear' 41 | status: ${{ steps.libyear.outputs.releases }} release(s) behind 42 | color: 'blue' 43 | path: 'badges/releases.svg' 44 | - uses: emibcn/badge-action@master 45 | with: 46 | label: 'libyear' 47 | status: ${{ steps.libyear.outputs.major }} major release(s) behind 48 | color: 'blue' 49 | path: 'badges/major.svg' 50 | - uses: emibcn/badge-action@master 51 | with: 52 | label: 'libyear' 53 | status: ${{ steps.libyear.outputs.minor }} minor release(s) behind 54 | color: 'blue' 55 | path: 'badges/minor.svg' 56 | - uses: emibcn/badge-action@master 57 | with: 58 | label: 'libyear' 59 | status: ${{ steps.libyear.outputs.patch }} patch release(s) behind 60 | color: 'blue' 61 | path: 'badges/patch.svg' 62 | - name: Push badges to gh-badges directory 63 | uses: s0/git-publish-subdir-action@v2.4.1 64 | env: 65 | REPO: self 66 | BRANCH: gh-badges 67 | FOLDER: badges 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | SQUASH_HISTORY: true 70 | 71 | -------------------------------------------------------------------------------- /action/test/specs/ssh-existing-branch.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Deploy to a existing branch over ssh', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Clone repo, and create an initial commit 15 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 16 | cwd: folders.workDir, 17 | }); 18 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial'), 'foobar'); 19 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 20 | await util.wrappedExec(`git config user.name "Test User"`, { 21 | cwd: folders.repoCloneDir, 22 | }); 23 | await util.wrappedExec(`git config user.email "test@example.com"`, { 24 | cwd: folders.repoCloneDir, 25 | }); 26 | await util.wrappedExec(`git commit -m initial`, { 27 | cwd: folders.repoCloneDir, 28 | }); 29 | await util.wrappedExec(`git push origin master`, { 30 | cwd: folders.repoCloneDir, 31 | }); 32 | 33 | // Create dummy data 34 | await mkdirP(path.join(folders.dataDir, 'dummy')); 35 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 36 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 37 | 38 | // Run Action 39 | await util.runWithGithubEnv( 40 | path.basename(__filename), 41 | { 42 | REPO: folders.repoUrl, 43 | BRANCH: 'master', 44 | FOLDER: folders.dataDir, 45 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 46 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 47 | }, 48 | 's0/test', 49 | {}, 50 | 's0' 51 | ); 52 | 53 | // Check that the log of the repo is as expected 54 | // (check tree-hash, commit message, and author) 55 | const log = ( 56 | await util.exec( 57 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 58 | { 59 | cwd: folders.repoDir, 60 | } 61 | ) 62 | ).stdout; 63 | const sha = await util.getRepoSha(); 64 | const cleanedLog = log.replace(sha, ''); 65 | expect(cleanedLog).toMatchSnapshot(); 66 | }); 67 | -------------------------------------------------------------------------------- /action/test/specs/ssh-custom-messages.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Test custom message templates', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 11 | 12 | // Create dummy data 13 | await mkdirP(path.join(folders.dataDir, 'dummy')); 14 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 15 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 16 | 17 | // Run Action 18 | await util.runWithGithubEnv( 19 | path.basename(__filename), 20 | { 21 | REPO: folders.repoUrl, 22 | BRANCH: 'branch-a', 23 | FOLDER: folders.dataDir, 24 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 25 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 26 | MESSAGE: 27 | 'This is a test message with placeholders:\n* {long-sha}\n* {sha}\n* {branch}', 28 | }, 29 | 's0/test', 30 | {}, 31 | 's0' 32 | ); 33 | // Run the action again to make sure that a commit is added even when there are 34 | // no content changes 35 | await util.runWithGithubEnv( 36 | path.basename(__filename), 37 | { 38 | REPO: folders.repoUrl, 39 | BRANCH: 'branch-a', 40 | FOLDER: folders.dataDir, 41 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 42 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 43 | MESSAGE: 'This is another commit follow up with no content changes', 44 | }, 45 | 's0/test', 46 | {}, 47 | 's0' 48 | ); 49 | 50 | // Check that the log of the repo is as expected 51 | // (check tree-hash, commit message, and author) 52 | // TODO: test {msg} placeholder and running action outside of a git repo 53 | let log = ( 54 | await util.exec( 55 | 'git log --pretty="format:msg:%B%ntree:%T%nauthor:%an <%ae>" branch-a', 56 | { 57 | cwd: folders.repoDir, 58 | } 59 | ) 60 | ).stdout; 61 | const fullSha = await util.getFullRepoSha(); 62 | const sha = fullSha.substr(0, 7); 63 | const cleanedLog = log.replace(fullSha, '').replace(sha, ''); 64 | expect(cleanedLog).toMatchSnapshot(); 65 | }); 66 | -------------------------------------------------------------------------------- /action/test/specs/ssh-existing-branch-folder-space.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Spaces in folder names are correctly handled', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Clone repo, and create an initial commit 15 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 16 | cwd: folders.workDir, 17 | }); 18 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial'), 'foobar'); 19 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 20 | await util.wrappedExec(`git config user.name "Test User"`, { 21 | cwd: folders.repoCloneDir, 22 | }); 23 | await util.wrappedExec(`git config user.email "test@example.com"`, { 24 | cwd: folders.repoCloneDir, 25 | }); 26 | await util.wrappedExec(`git commit -m initial`, { 27 | cwd: folders.repoCloneDir, 28 | }); 29 | await util.wrappedExec(`git push origin master`, { 30 | cwd: folders.repoCloneDir, 31 | }); 32 | 33 | // Create dummy data 34 | await mkdirP(path.join(folders.dataDir, 'dummy foo')); 35 | await fs.writeFile(path.join(folders.dataDir, 'dummy foo', 'baz'), 'foobar'); 36 | await fs.writeFile(path.join(folders.dataDir, 'dummy foo', '.bat'), 'foobar'); 37 | 38 | // Run Action 39 | await util.runWithGithubEnv( 40 | path.basename(__filename), 41 | { 42 | REPO: folders.repoUrl, 43 | BRANCH: 'master', 44 | FOLDER: folders.dataDir, 45 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 46 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 47 | }, 48 | 's0/test', 49 | {}, 50 | 's0' 51 | ); 52 | 53 | // Check that the log of the repo is as expected 54 | // (check tree-hash, commit message, and author) 55 | const log = ( 56 | await util.exec( 57 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 58 | { 59 | cwd: folders.repoDir, 60 | } 61 | ) 62 | ).stdout; 63 | const sha = await util.getRepoSha(); 64 | const cleanedLog = log.replace(sha, ''); 65 | expect(cleanedLog).toMatchSnapshot(); 66 | }); 67 | -------------------------------------------------------------------------------- /action/test/specs/ssh-skip-empty-commits.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Skip empty commits', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 12 | 13 | // Create dummy data 14 | await mkdirP(path.join(folders.dataDir, 'dummy')); 15 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 16 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 17 | 18 | // Run Action 19 | await util.runWithGithubEnv( 20 | path.basename(__filename), 21 | { 22 | REPO: folders.repoUrl, 23 | BRANCH: 'branch-a', 24 | FOLDER: folders.dataDir, 25 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 26 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 27 | SKIP_EMPTY_COMMITS: 'true', 28 | }, 29 | 's0/test', 30 | {}, 31 | 's0' 32 | ); 33 | const fullSha1 = await util.getFullRepoSha(); 34 | // Change files and run action again 35 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'bat'), 'foobar'); 36 | await util.runWithGithubEnv( 37 | path.basename(__filename), 38 | { 39 | REPO: folders.repoUrl, 40 | BRANCH: 'branch-a', 41 | FOLDER: folders.dataDir, 42 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 43 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 44 | SKIP_EMPTY_COMMITS: 'true', 45 | }, 46 | 's0/test', 47 | {}, 48 | 's0' 49 | ); 50 | const fullSha2 = await util.getFullRepoSha(); 51 | // Run the action again with no content changes to test skip behaviour 52 | await util.runWithGithubEnv( 53 | path.basename(__filename), 54 | { 55 | REPO: folders.repoUrl, 56 | BRANCH: 'branch-a', 57 | FOLDER: folders.dataDir, 58 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 59 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 60 | SKIP_EMPTY_COMMITS: 'true', 61 | }, 62 | 's0/test', 63 | {}, 64 | 's0' 65 | ); 66 | 67 | // Check that the log of the repo is as expected 68 | // (check tree-hash, commit message, and author) 69 | // TODO: test {msg} placeholder and running action outside of a git repo 70 | let log = ( 71 | await util.exec( 72 | 'git log --pretty="format:msg:%B%ntree:%T%nauthor:%an <%ae>" branch-a', 73 | { 74 | cwd: folders.repoDir, 75 | } 76 | ) 77 | ).stdout; 78 | const sha1 = fullSha1.substr(0, 7); 79 | const sha2 = fullSha2.substr(0, 7); 80 | const cleanedLog = log.replace(sha1, '').replace(sha2, ''); 81 | expect(cleanedLog).toMatchSnapshot(); 82 | }); 83 | -------------------------------------------------------------------------------- /action/test/specs/ssh-existing-branch-squash.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Deploy to a existing branch over ssh, and squash commits', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Clone repo, and create an initial commit 15 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 16 | cwd: folders.workDir, 17 | }); 18 | await util.wrappedExec(`git config user.name "Test User"`, { 19 | cwd: folders.repoCloneDir, 20 | }); 21 | await util.wrappedExec(`git config user.email "test@example.com"`, { 22 | cwd: folders.repoCloneDir, 23 | }); 24 | // Create first commit 25 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial'), 'foobar'); 26 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 27 | await util.wrappedExec(`git commit -m initial`, { 28 | cwd: folders.repoCloneDir, 29 | }); 30 | await util.wrappedExec(`git push origin master`, { 31 | cwd: folders.repoCloneDir, 32 | }); 33 | // Create second commit 34 | await fs.writeFile(path.join(folders.repoCloneDir, 'secondary'), 'foobar'); 35 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 36 | await util.wrappedExec(`git commit -m secondary`, { 37 | cwd: folders.repoCloneDir, 38 | }); 39 | await util.wrappedExec(`git push origin master`, { 40 | cwd: folders.repoCloneDir, 41 | }); 42 | 43 | // Create dummy data 44 | await mkdirP(folders.dataDir); 45 | await mkdirP(path.join(folders.dataDir, 'dummy')); 46 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 47 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 48 | 49 | // Run Action 50 | await util.runWithGithubEnv( 51 | path.basename(__filename), 52 | { 53 | REPO: folders.repoUrl, 54 | BRANCH: 'master', 55 | FOLDER: folders.dataDir, 56 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 57 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 58 | SQUASH_HISTORY: 'true', 59 | }, 60 | 's0/test', 61 | {}, 62 | 's0' 63 | ); 64 | 65 | // Check that the log of the repo is as expected 66 | // (check tree-hash, commit message, and author) 67 | const log = ( 68 | await util.exec( 69 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 70 | { 71 | cwd: folders.repoDir, 72 | } 73 | ) 74 | ).stdout; 75 | const sha = await util.getRepoSha(); 76 | const cleanedLog = log.replace(sha, ''); 77 | expect(cleanedLog).toMatchSnapshot(); 78 | }); 79 | -------------------------------------------------------------------------------- /action/test/specs/ssh-target-dir-no-exists.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | import { listTree } from '../util/git'; 8 | 9 | it('Deploy to a branch on a custom dir that does not exist', async () => { 10 | const folders = await prepareTestFolders({ __filename }); 11 | 12 | // Create empty repo 13 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 14 | 15 | // Clone repo, and create an initial commit 16 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 17 | cwd: folders.workDir, 18 | }); 19 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial1'), 'foobar1'); 20 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial2'), 'foobar2'); 21 | await mkdirP(path.join(folders.repoCloneDir, 'folder')); 22 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'a'), 'foobar1'); 23 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'b'), 'foobar2'); 24 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 25 | await util.wrappedExec(`git config user.name "Test User"`, { 26 | cwd: folders.repoCloneDir, 27 | }); 28 | await util.wrappedExec(`git config user.email "test@example.com"`, { 29 | cwd: folders.repoCloneDir, 30 | }); 31 | await util.wrappedExec(`git commit -m initial`, { 32 | cwd: folders.repoCloneDir, 33 | }); 34 | await util.wrappedExec(`git push origin master`, { 35 | cwd: folders.repoCloneDir, 36 | }); 37 | 38 | // Create dummy data 39 | await mkdirP(path.join(folders.dataDir, 'dummy')); 40 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 41 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 42 | 43 | // Run Action 44 | await util.runWithGithubEnv( 45 | path.basename(__filename), 46 | { 47 | REPO: folders.repoUrl, 48 | BRANCH: 'master', 49 | FOLDER: folders.dataDir, 50 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 51 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 52 | TARGET_DIR: 'custom', 53 | }, 54 | 's0/test', 55 | {}, 56 | 's0' 57 | ); 58 | 59 | // Check that the list of files in the root of the target repo is as expected 60 | expect(await listTree(folders.repoDir)).toEqual([ 61 | '.', 62 | 'custom', 63 | 'custom/dummy', 64 | 'custom/dummy/.bat', 65 | 'custom/dummy/baz', 66 | 'folder', 67 | 'folder/a', 68 | 'folder/b', 69 | 'initial1', 70 | 'initial2', 71 | ]); 72 | 73 | // Check that the log of the repo is as expected 74 | // (check tree-hash, commit message, and author) 75 | const log = ( 76 | await util.exec( 77 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 78 | { 79 | cwd: folders.repoDir, 80 | } 81 | ) 82 | ).stdout; 83 | const sha = await util.getRepoSha(); 84 | const cleanedLog = log.replace(sha, ''); 85 | expect(cleanedLog).toMatchSnapshot(); 86 | }); 87 | -------------------------------------------------------------------------------- /action/test/specs/ssh-custom-tags.spec.ts: -------------------------------------------------------------------------------- 1 | import fsModule, { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import git from 'isomorphic-git'; 4 | import { mkdirP } from '@actions/io'; 5 | 6 | import * as util from '../util'; 7 | import { prepareTestFolders } from '../util/io'; 8 | 9 | it('Test custom tags', async () => { 10 | const folders = await prepareTestFolders({ __filename }); 11 | 12 | // Create empty repo 13 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 14 | 15 | // Create dummy data 16 | await mkdirP(path.join(folders.dataDir, 'dummy')); 17 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 18 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 19 | 20 | // Run Action 21 | await util.runWithGithubEnv( 22 | path.basename(__filename), 23 | { 24 | REPO: folders.repoUrl, 25 | BRANCH: 'branch-a', 26 | FOLDER: folders.dataDir, 27 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 28 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 29 | }, 30 | 's0/test', 31 | {}, 32 | 's0' 33 | ); 34 | // Run the action again to make sure that a commit is added even when there are 35 | // no content changes 36 | await util.runWithGithubEnv( 37 | path.basename(__filename), 38 | { 39 | REPO: folders.repoUrl, 40 | BRANCH: 'branch-a', 41 | FOLDER: folders.dataDir, 42 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 43 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 44 | MESSAGE: 'This is another commit follow up with no content changes', 45 | TAG: 'foo-bar-tag-v0.1.2', 46 | }, 47 | 's0/test', 48 | {}, 49 | 's0' 50 | ); 51 | 52 | { 53 | // Check that the log of the branch is as expected 54 | let log = ( 55 | await util.exec( 56 | 'git log --pretty="format:msg:%B%ntree:%T%nauthor:%an <%ae>" branch-a', 57 | { 58 | cwd: folders.repoDir, 59 | } 60 | ) 61 | ).stdout; 62 | const fullSha = await util.getFullRepoSha(); 63 | const sha = fullSha.substr(0, 7); 64 | const cleanedLog = log.replace(fullSha, '').replace(sha, ''); 65 | expect(cleanedLog).toMatchSnapshot(); 66 | } 67 | 68 | { 69 | // Check that the log got the tag is also as expected 70 | let log = ( 71 | await util.exec( 72 | 'git log --pretty="format:msg:%B%ntree:%T%nauthor:%an <%ae>" foo-bar-tag-v0.1.2', 73 | { 74 | cwd: folders.repoDir, 75 | } 76 | ) 77 | ).stdout; 78 | const fullSha = await util.getFullRepoSha(); 79 | const sha = fullSha.substr(0, 7); 80 | const cleanedLog = log.replace(fullSha, '').replace(sha, ''); 81 | expect(cleanedLog).toMatchSnapshot(); 82 | } 83 | 84 | // Ensure that commits for branch and tag are identical 85 | const tagSha = await git.resolveRef({ 86 | fs: fsModule, 87 | gitdir: folders.repoDir, 88 | ref: 'refs/tags/foo-bar-tag-v0.1.2', 89 | }); 90 | const branchSha = await git.resolveRef({ 91 | fs: fsModule, 92 | gitdir: folders.repoDir, 93 | ref: 'refs/heads/branch-a', 94 | }); 95 | expect(tagSha).toEqual(branchSha); 96 | }); 97 | -------------------------------------------------------------------------------- /action/test/specs/ssh-existing-branch-custom-rm-globs.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { listTree } from '../util/git'; 7 | import { prepareTestFolders } from '../util/io'; 8 | 9 | it('Check that only target deleted files are removed', async () => { 10 | const folders = await prepareTestFolders({ __filename }); 11 | 12 | // Create empty repo 13 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 14 | 15 | // Clone repo, and create an initial commit 16 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 17 | cwd: folders.workDir, 18 | }); 19 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial1'), 'foobar1'); 20 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial2'), 'foobar2'); 21 | await mkdirP(path.join(folders.repoCloneDir, 'folder')); 22 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'a'), 'foobar1'); 23 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'b'), 'foobar2'); 24 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 25 | await util.wrappedExec(`git config user.name "Test User"`, { 26 | cwd: folders.repoCloneDir, 27 | }); 28 | await util.wrappedExec(`git config user.email "test@example.com"`, { 29 | cwd: folders.repoCloneDir, 30 | }); 31 | await util.wrappedExec(`git commit -m initial`, { 32 | cwd: folders.repoCloneDir, 33 | }); 34 | await util.wrappedExec(`git push origin master`, { 35 | cwd: folders.repoCloneDir, 36 | }); 37 | 38 | // Create dummy data 39 | await mkdirP(path.join(folders.dataDir, 'dummy')); 40 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 41 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 42 | 43 | // Setup globs 44 | const globPath = path.join(folders.workDir, '.globs'); 45 | await fs.writeFile( 46 | globPath, 47 | ` 48 | folder/* 49 | !folder/a 50 | ini*al2 51 | ` 52 | ); 53 | 54 | // Run Action 55 | await util.runWithGithubEnv( 56 | path.basename(__filename), 57 | { 58 | REPO: folders.repoUrl, 59 | BRANCH: 'master', 60 | FOLDER: folders.dataDir, 61 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 62 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 63 | CLEAR_GLOBS_FILE: globPath, 64 | }, 65 | 's0/test', 66 | {}, 67 | 's0' 68 | ); 69 | 70 | // Check that the list of files in the root of the target repo is as expected 71 | expect(await listTree(folders.repoDir)).toEqual([ 72 | '.', 73 | 'dummy', 74 | 'dummy/.bat', 75 | 'dummy/baz', 76 | 'folder', 77 | 'folder/a', 78 | 'initial1', 79 | ]); 80 | 81 | // Check that the log of the repo is as expected 82 | // (check tree-hash, commit message, and author) 83 | const log = ( 84 | await util.exec( 85 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 86 | { 87 | cwd: folders.repoDir, 88 | } 89 | ) 90 | ).stdout; 91 | const sha = await util.getRepoSha(); 92 | const cleanedLog = log.replace(sha, ''); 93 | expect(cleanedLog).toMatchSnapshot(); 94 | }); 95 | -------------------------------------------------------------------------------- /action/test/specs/ssh-target-dir-exists.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | import { listTree } from '../util/git'; 8 | 9 | it('Deploy to a branch on a custom dir that exists', async () => { 10 | const folders = await prepareTestFolders({ __filename }); 11 | 12 | // Create empty repo 13 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 14 | 15 | // Clone repo, and create an initial commit 16 | await util.wrappedExec(`git clone "${folders.repoDir}" clone`, { 17 | cwd: folders.workDir, 18 | }); 19 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial1'), 'foobar1'); 20 | await fs.writeFile(path.join(folders.repoCloneDir, 'initial2'), 'foobar2'); 21 | await mkdirP(path.join(folders.repoCloneDir, 'folder')); 22 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'a'), 'foobar1'); 23 | await fs.writeFile(path.join(folders.repoCloneDir, 'folder', 'b'), 'foobar2'); 24 | await mkdirP(path.join(folders.repoCloneDir, 'custom', 'b')); 25 | await fs.writeFile(path.join(folders.repoCloneDir, 'custom', 'a'), 'foobar1'); 26 | await fs.writeFile( 27 | path.join(folders.repoCloneDir, 'custom', 'b', 'c'), 28 | 'foobar1' 29 | ); 30 | await util.wrappedExec(`git add -A .`, { cwd: folders.repoCloneDir }); 31 | await util.wrappedExec(`git config user.name "Test User"`, { 32 | cwd: folders.repoCloneDir, 33 | }); 34 | await util.wrappedExec(`git config user.email "test@example.com"`, { 35 | cwd: folders.repoCloneDir, 36 | }); 37 | await util.wrappedExec(`git commit -m initial`, { 38 | cwd: folders.repoCloneDir, 39 | }); 40 | await util.wrappedExec(`git push origin master`, { 41 | cwd: folders.repoCloneDir, 42 | }); 43 | 44 | // Create dummy data 45 | await mkdirP(path.join(folders.dataDir, 'dummy')); 46 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 47 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 48 | 49 | // Run Action 50 | await util.runWithGithubEnv( 51 | path.basename(__filename), 52 | { 53 | REPO: folders.repoUrl, 54 | BRANCH: 'master', 55 | FOLDER: folders.dataDir, 56 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 57 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 58 | TARGET_DIR: 'custom', 59 | }, 60 | 's0/test', 61 | {}, 62 | 's0' 63 | ); 64 | 65 | // Check that the list of files in the root of the target repo is as expected 66 | expect(await listTree(folders.repoDir)).toEqual([ 67 | '.', 68 | 'custom', 69 | 'custom/dummy', 70 | 'custom/dummy/.bat', 71 | 'custom/dummy/baz', 72 | 'folder', 73 | 'folder/a', 74 | 'folder/b', 75 | 'initial1', 76 | 'initial2', 77 | ]); 78 | 79 | // Check that the log of the repo is as expected 80 | // (check tree-hash, commit message, and author) 81 | const log = ( 82 | await util.exec( 83 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" master', 84 | { 85 | cwd: folders.repoDir, 86 | } 87 | ) 88 | ).stdout; 89 | const sha = await util.getRepoSha(); 90 | const cleanedLog = log.replace(sha, ''); 91 | expect(cleanedLog).toMatchSnapshot(); 92 | }); 93 | -------------------------------------------------------------------------------- /.github/workflows/cron.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled tests 2 | on: 3 | schedule: 4 | - cron: '0 10 * * *' 5 | 6 | jobs: 7 | deploy-ssh-no-branch: 8 | name: Test deploying to a new branch 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Setup Dummy Data 13 | run: | 14 | mkdir dummy 15 | echo "foobar" > "dummy/baz" 16 | - name: Setup SSH Keys and known_hosts 17 | env: 18 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 19 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 20 | run: | 21 | mkdir -p ~/.ssh 22 | cp action/resources/known_hosts_github.com ~/.ssh/known_hosts 23 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 24 | ssh-add - <<< "${SSH_PRIVATE_KEY}" 25 | - name: Delete existing banch 26 | env: 27 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 28 | run: git push git@github.com:s0/git-publish-subdir-action-tests.git +:refs/heads/branch-a 29 | - name: Deploy 30 | uses: ./ 31 | env: 32 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 33 | BRANCH: branch-a 34 | FOLDER: dummy 35 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 36 | deploy-ssh-existing-branch: 37 | name: Test deploying to a pre-existing branch 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@master 41 | - name: Setup Dummy Data 42 | run: | 43 | mkdir dummy 44 | echo "foobar" > "dummy/baz" 45 | - name: Deploy 46 | uses: ./ 47 | env: 48 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 49 | BRANCH: branch-b 50 | FOLDER: dummy 51 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 52 | deploy-ssh-existing-branch-known_hosts: 53 | name: Test deploying to a pre-existing branch (with KNOWN_HOSTS_FILE) 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@master 57 | - name: Setup Dummy Data 58 | run: | 59 | mkdir dummy 60 | echo "foobar" > "dummy/baz" 61 | - name: Deploy 62 | uses: ./ 63 | env: 64 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 65 | BRANCH: branch-c 66 | FOLDER: dummy 67 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 68 | KNOWN_HOSTS_FILE: action/resources/known_hosts_github.com 69 | deploy-ssh-twice: 70 | name: Test deploying multiple times in one job 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@master 74 | - name: Setup Dummy Data 75 | run: | 76 | mkdir dummy1 77 | echo "foobar1" > "dummy1/baz" 78 | mkdir dummy2 79 | echo "foobar2" > "dummy2/baz" 80 | - name: Deploy 81 | uses: ./ 82 | env: 83 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 84 | BRANCH: branch-d 85 | FOLDER: dummy1 86 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 87 | - name: Deploy 88 | uses: ./ 89 | env: 90 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 91 | BRANCH: branch-d 92 | FOLDER: dummy2 93 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 94 | deploy-locally: 95 | name: Test deploying to another branch of same repo 96 | runs-on: ubuntu-latest 97 | steps: 98 | - uses: actions/checkout@master 99 | - name: Setup Dummy Data 100 | run: | 101 | mkdir dummy 102 | echo "foobar" > "dummy/baz" 103 | - name: Deploy 104 | uses: ./ 105 | env: 106 | REPO: self 107 | BRANCH: test-branch 108 | FOLDER: dummy 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /action/test/specs/ssh-no-branch-custom-pusher.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import * as path from 'path'; 3 | import { mkdirP } from '@actions/io'; 4 | 5 | import * as util from '../util'; 6 | import { prepareTestFolders } from '../util/io'; 7 | 8 | it('Custom Pusher', async () => { 9 | const folders = await prepareTestFolders({ __filename }); 10 | 11 | // Create empty repo 12 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 13 | 14 | // Create dummy data 15 | await mkdirP(path.join(folders.dataDir, 'dummy')); 16 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 17 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 18 | 19 | // Run Action 20 | await util.runWithGithubEnv( 21 | path.basename(__filename), 22 | { 23 | REPO: folders.repoUrl, 24 | BRANCH: 'branch-a', 25 | FOLDER: folders.dataDir, 26 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 27 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 28 | }, 29 | 's0/test', 30 | { 31 | pusher: { 32 | email: 'bob@examle.com', 33 | name: 'Alice Bob', 34 | }, 35 | }, 36 | 's0' 37 | ); 38 | 39 | // Check that the log of the repo is as expected 40 | // (check tree-hash, commit message, and author) 41 | const log = ( 42 | await util.exec( 43 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 44 | { 45 | cwd: folders.repoDir, 46 | } 47 | ) 48 | ).stdout; 49 | const sha = await util.getRepoSha(); 50 | const cleanedLog = log.replace(sha, ''); 51 | expect(cleanedLog).toMatchSnapshot(); 52 | }); 53 | 54 | it('Custom Pusher (invalid)', async () => { 55 | const folders = await prepareTestFolders({ __filename }); 56 | 57 | // Create empty repo 58 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 59 | 60 | // Create dummy data 61 | await mkdirP(path.join(folders.dataDir, 'dummy')); 62 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 63 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 64 | 65 | // Run Action 66 | await util.runWithGithubEnv( 67 | path.basename(__filename), 68 | { 69 | REPO: folders.repoUrl, 70 | BRANCH: 'branch-a', 71 | FOLDER: folders.dataDir, 72 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 73 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 74 | }, 75 | 's0/test', 76 | { 77 | pusher: {}, 78 | }, 79 | 's0' 80 | ); 81 | 82 | // Check that the log of the repo is as expected 83 | // (check tree-hash, commit message, and author) 84 | const log = ( 85 | await util.exec( 86 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 87 | { 88 | cwd: folders.repoDir, 89 | } 90 | ) 91 | ).stdout; 92 | const sha = await util.getRepoSha(); 93 | const cleanedLog = log.replace(sha, ''); 94 | expect(cleanedLog).toMatchSnapshot(); 95 | }); 96 | 97 | it('No Pusher or Actor', async () => { 98 | const folders = await prepareTestFolders({ __filename }); 99 | 100 | // Create empty repo 101 | await util.wrappedExec('git init --bare', { cwd: folders.repoDir }); 102 | 103 | // Create dummy data 104 | await mkdirP(path.join(folders.dataDir, 'dummy')); 105 | await fs.writeFile(path.join(folders.dataDir, 'dummy', 'baz'), 'foobar'); 106 | await fs.writeFile(path.join(folders.dataDir, 'dummy', '.bat'), 'foobar'); 107 | 108 | // Run Action 109 | await util.runWithGithubEnv( 110 | path.basename(__filename), 111 | { 112 | REPO: folders.repoUrl, 113 | BRANCH: 'branch-a', 114 | FOLDER: folders.dataDir, 115 | SSH_PRIVATE_KEY: (await fs.readFile(util.SSH_PRIVATE_KEY)).toString(), 116 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 117 | }, 118 | 's0/test' 119 | ); 120 | 121 | // Check that the log of the repo is as expected 122 | // (check tree-hash, commit message, and author) 123 | const log = ( 124 | await util.exec( 125 | 'git log --pretty="format:msg:%s%ntree:%T%nauthor:%an <%ae>" branch-a', 126 | { 127 | cwd: folders.repoDir, 128 | } 129 | ) 130 | ).stdout; 131 | const sha = await util.getRepoSha(); 132 | const cleanedLog = log.replace(sha, ''); 133 | expect(cleanedLog).toMatchSnapshot(); 134 | }); 135 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test branch 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | deploy-ssh-no-branch: 8 | name: Test deploying to a new branch 9 | runs-on: ubuntu-latest 10 | # do not run from forks, as forks don’t have access to repository secrets 11 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Setup Dummy Data 15 | run: | 16 | mkdir dummy 17 | echo "foobar" > "dummy/baz" 18 | echo "foobar" > "dummy/.bat" 19 | - name: Setup SSH Keys and known_hosts 20 | env: 21 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 22 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 23 | run: | 24 | mkdir -p ~/.ssh 25 | cp action/resources/known_hosts_github.com ~/.ssh/known_hosts 26 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 27 | ssh-add - <<< "${SSH_PRIVATE_KEY}" 28 | - name: Delete existing banch 29 | env: 30 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 31 | run: git push git@github.com:s0/git-publish-subdir-action-tests.git +:refs/heads/branch-a 32 | - name: Deploy 33 | uses: ./ 34 | env: 35 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 36 | BRANCH: branch-a 37 | FOLDER: dummy 38 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 39 | deploy-ssh-existing-branch: 40 | name: Test deploying to a pre-existing branch 41 | runs-on: ubuntu-latest 42 | # do not run from forks, as forks don’t have access to repository secrets 43 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 44 | steps: 45 | - uses: actions/checkout@master 46 | - name: Setup Dummy Data 47 | run: | 48 | mkdir dummy 49 | echo "foobar" > "dummy/baz" 50 | echo "foobar" > "dummy/.bat" 51 | - name: Deploy 52 | uses: ./ 53 | env: 54 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 55 | BRANCH: branch-b 56 | FOLDER: dummy 57 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 58 | deploy-ssh-existing-branch-known_hosts: 59 | name: Test deploying to a pre-existing branch (with KNOWN_HOSTS_FILE) 60 | runs-on: ubuntu-latest 61 | # do not run from forks, as forks don’t have access to repository secrets 62 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 63 | steps: 64 | - uses: actions/checkout@master 65 | - name: Setup Dummy Data 66 | run: | 67 | mkdir dummy 68 | echo "foobar" > "dummy/baz" 69 | echo "foobar" > "dummy/.bat" 70 | - name: Deploy 71 | uses: ./ 72 | env: 73 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 74 | BRANCH: branch-c 75 | FOLDER: dummy 76 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 77 | KNOWN_HOSTS_FILE: action/resources/known_hosts_github.com 78 | deploy-ssh-twice: 79 | name: Test deploying multiple times in one job 80 | runs-on: ubuntu-latest 81 | # do not run from forks, as forks don’t have access to repository secrets 82 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 83 | steps: 84 | - uses: actions/checkout@master 85 | - name: Setup Dummy Data 86 | run: | 87 | mkdir dummy1 88 | echo "foobar1" > "dummy1/baz" 89 | echo "foobar1" > "dummy1/.bat" 90 | mkdir dummy2 91 | echo "foobar2" > "dummy2/baz" 92 | echo "foobar2" > "dummy2/.bat" 93 | - name: Deploy 94 | uses: ./ 95 | env: 96 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 97 | BRANCH: branch-d 98 | FOLDER: dummy1 99 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 100 | - name: Deploy 101 | uses: ./ 102 | env: 103 | REPO: git@github.com:s0/git-publish-subdir-action-tests.git 104 | BRANCH: branch-d 105 | FOLDER: dummy2 106 | SSH_PRIVATE_KEY: ${{ secrets.TESTING_PRIVATE_KEY }} 107 | deploy-locally: 108 | name: Test deploying to another branch of same repo 109 | runs-on: ubuntu-latest 110 | # do not run from forks, as forks don’t have access to repository secrets 111 | if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 112 | steps: 113 | - uses: actions/checkout@master 114 | - name: Setup Dummy Data 115 | run: | 116 | mkdir dummy 117 | echo "foobar" > "dummy/baz" 118 | echo "foobar" > "dummy/.bat" 119 | - name: Deploy 120 | uses: ./ 121 | env: 122 | REPO: self 123 | BRANCH: test-branch 124 | FOLDER: dummy 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | -------------------------------------------------------------------------------- /action/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /action/test/util.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import * as child_process from 'child_process'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import { promisify, format } from 'util'; 6 | 7 | import { EnvironmentVariables, Console, Event, main } from '../src'; 8 | 9 | dotenv.config(); 10 | 11 | export const exec = promisify(child_process.exec); 12 | export const mkdir = promisify(fs.mkdir); 13 | export const writeFile = promisify(fs.writeFile); 14 | export const readFile = promisify(fs.readFile); 15 | 16 | export const TEST_DIR = __dirname; 17 | export const DATA_DIR = path.join(TEST_DIR, 'data'); 18 | export const REPOS_DIR = path.join(DATA_DIR, 'repos'); 19 | export const SSH_PRIVATE_KEY = path.join(DATA_DIR, 'id'); 20 | export const SSH_PRIVATE_KEY_INVALID = path.join(DATA_DIR, 'id2'); 21 | export const KNOWN_HOSTS = path.join(DATA_DIR, 'known_hosts'); 22 | 23 | export const DOCKER_IMAGE_TEST_DIR = '/home/node/repo/action/test' 24 | 25 | export const NODE_CONTAINER = 'test-node'; 26 | 27 | export const getGitHubSSHPrivateKey = () => { 28 | const key = process.env.GITHUB_SSH_PRIVATE_KEY; 29 | if (!key) 30 | throw new Error('Environment variable GITHUB_SSH_PRIVATE_KEY not set, needed for tests'); 31 | return key; 32 | } 33 | 34 | export const wrappedExec = async ( 35 | command: string, 36 | opts?: child_process.ExecOptions, 37 | extra?: { 38 | logToConsole?: true; 39 | } 40 | ) => { 41 | const result = await exec(command, opts); 42 | const stdout = result.stdout.toString(); 43 | const stderr = result.stderr.toString(); 44 | if (stderr.length > 0 && extra?.logToConsole) { 45 | console.log(stderr); 46 | } 47 | if (stdout.length > 0 && extra?.logToConsole) { 48 | console.log(stdout); 49 | } 50 | return result; 51 | } 52 | 53 | interface RunOptions { 54 | debug?: boolean; 55 | captureOutput?: boolean; 56 | }; 57 | 58 | interface TestRunOutput { 59 | stdout: string; 60 | stderr: string; 61 | } 62 | 63 | export class TestRunError extends Error { 64 | public readonly output?: TestRunOutput; 65 | public constructor(message: string, output?: TestRunOutput) { 66 | super(message); 67 | this.output = output; 68 | } 69 | } 70 | 71 | export const runWithEnv = async ( 72 | reportName: string, 73 | env: EnvironmentVariables, 74 | opts?: RunOptions, 75 | ) => { 76 | 77 | const envVars: string[] = []; 78 | 79 | for (let [key, value] of Object.entries(env)) { 80 | if (typeof value === 'string') { 81 | envVars.push('-e'); 82 | // Replace paths that are relative to the host to be relative to the docker image 83 | if (value.startsWith(TEST_DIR)) { 84 | value = value.replace(TEST_DIR, DOCKER_IMAGE_TEST_DIR); 85 | } 86 | envVars.push(`${key}=${value}`); 87 | } 88 | } 89 | 90 | const nodeCmd = [ 91 | 'node', 92 | ... (opts?.debug ? ['--inspect-brk'] : []), 93 | '-r', 94 | 'ts-node/register/transpile-only', 95 | 'src' 96 | ]; 97 | 98 | const uid = process.getuid().toString(); 99 | 100 | const ps = child_process.spawn( 101 | 'docker', 102 | ['exec', ...envVars, '-u', uid, 'test-node', 'npx', 'nyc', '--temp-dir', `./.nyc_output/${reportName}`, '--reporter=none', ...nodeCmd], 103 | { 104 | env: { 105 | ...process.env, 106 | ...env 107 | }, 108 | stdio: opts?.captureOutput ? 'pipe' : 'inherit', 109 | }, 110 | ); 111 | 112 | let output: TestRunOutput | undefined = undefined; 113 | if (opts?.captureOutput) { 114 | const o = output = { 115 | stderr: '', 116 | stdout: '' 117 | }; 118 | 119 | for (const stream of ['stdout', 'stderr'] as const) { 120 | ps[stream]?.on('data', data => { 121 | o[stream] += data; 122 | }); 123 | } 124 | } 125 | 126 | return new Promise((resolve, reject) => ps.on('close', code => { 127 | if (code !== 0) { 128 | reject(new TestRunError('Process exited with code: ' + code, output)); 129 | } else { 130 | resolve(output); 131 | } 132 | })); 133 | } 134 | 135 | interface ExtendedRunOptions extends RunOptions { 136 | excludeEventPath?: true; 137 | logToConsole?: true; 138 | } 139 | 140 | export const runWithGithubEnv = async ( 141 | reportName: string, 142 | env: EnvironmentVariables, 143 | repo: string | undefined, 144 | event?: Event, 145 | actor?: string, 146 | opts?: ExtendedRunOptions, 147 | ) => { 148 | // create event file 149 | const file = path.join(DATA_DIR, `event-${new Date().getTime()}.json`); 150 | await writeFile(file, JSON.stringify(event || {})); 151 | 152 | const finalEnv: EnvironmentVariables = { 153 | ...env, 154 | ...(actor ? { GITHUB_ACTOR: actor } : {}), 155 | ...(repo ? { GITHUB_REPOSITORY: repo } : {}), 156 | ...(opts?.excludeEventPath ? {} : { GITHUB_EVENT_PATH: file }), 157 | } 158 | 159 | const output = { 160 | stderr: '', 161 | stdout: '' 162 | }; 163 | const log: Console = { 164 | log: (...msg: unknown[]) => { 165 | output.stdout += msg.map(format).join(' ') + '\n'; 166 | if (opts?.logToConsole) { 167 | console.log(...msg); 168 | } 169 | }, 170 | error: (...msg: unknown[]) => { 171 | output.stderr += msg.map(format).join(' ') + '\n'; 172 | if (opts?.logToConsole) { 173 | console.error(...msg); 174 | } 175 | }, 176 | warn: (...msg: unknown[]) => { 177 | output.stderr += msg.map(format).join(' ') + '\n'; 178 | if (opts?.logToConsole) { 179 | console.warn(...msg); 180 | } 181 | }, 182 | } as const; 183 | 184 | return await main({ 185 | env: finalEnv, 186 | log: log 187 | }).then(async result => { 188 | return result; 189 | }).catch(async err => { 190 | output.stderr += format(err) + '\n'; 191 | throw new TestRunError(format(err), output); 192 | }); 193 | } 194 | 195 | /** 196 | * Get the full sha of this repo 197 | */ 198 | export const getFullRepoSha = () => 199 | exec(`git rev-parse HEAD`, { cwd: TEST_DIR }) 200 | .then(r => r.stdout.trim()); 201 | 202 | /** 203 | * Get the short sha of this repo 204 | */ 205 | export const getRepoSha = () => 206 | getFullRepoSha().then(sha => sha.substr(0, 7)); 207 | -------------------------------------------------------------------------------- /action/test/specs/misconfiguration.spec.ts: -------------------------------------------------------------------------------- 1 | import * as util from '../util'; 2 | import { prepareTestFolders } from '../util/io'; 3 | 4 | const KNOWN_HOSTS_WARNING = ` 5 | ##[warning] KNOWN_HOSTS_FILE not set 6 | This will probably mean that host verification will fail later on 7 | `; 8 | 9 | const KNOWN_HOSTS_ERROR = ` 10 | ##[error] Host key verification failed! 11 | This is probably because you forgot to supply a value for KNOWN_HOSTS_FILE 12 | or the file is invalid or doesn't correctly verify the host git-ssh 13 | `; 14 | 15 | const SSH_KEY_ERROR = ` 16 | ##[error] Permission denied (publickey) 17 | Make sure that the ssh private key is set correctly, and 18 | that the public key has been added to the target repo 19 | `; 20 | 21 | describe('Misconfigurations', () => { 22 | xit('missing-known-hosts', async () => { 23 | const folders = await prepareTestFolders({ __filename }); 24 | 25 | // Run Action 26 | await util 27 | .runWithGithubEnv( 28 | folders.testName, 29 | { 30 | REPO: folders.repoUrl, 31 | BRANCH: 'branch-a', 32 | FOLDER: folders.dataDir, 33 | SSH_PRIVATE_KEY: ( 34 | await util.readFile(util.SSH_PRIVATE_KEY) 35 | ).toString(), 36 | }, 37 | 's0/test', 38 | {}, 39 | 's0', 40 | { 41 | captureOutput: true, 42 | } 43 | ) 44 | .then(() => { 45 | throw new Error('Expected error'); 46 | }) 47 | .catch((err: util.TestRunError) => { 48 | try { 49 | expect(err.output).toBeDefined(); 50 | expect(err.output?.stderr.includes(KNOWN_HOSTS_WARNING)).toBeTruthy(); 51 | expect(err.output?.stderr.includes(KNOWN_HOSTS_ERROR)).toBeTruthy(); 52 | } catch (e) { 53 | console.log(err); 54 | throw e; 55 | } 56 | }); 57 | }); 58 | 59 | it('missing-repo', async () => { 60 | const folders = await prepareTestFolders({ __filename }); 61 | 62 | // Run Action 63 | await util 64 | .runWithGithubEnv( 65 | folders.testName, 66 | { 67 | BRANCH: 'branch-a', 68 | FOLDER: folders.dataDir, 69 | }, 70 | 's0/test', 71 | {}, 72 | 's0', 73 | { 74 | captureOutput: true, 75 | } 76 | ) 77 | .then(() => { 78 | throw new Error('Expected error'); 79 | }) 80 | .catch((err: util.TestRunError) => { 81 | try { 82 | expect(err.output).toBeDefined(); 83 | expect( 84 | err.output?.stderr.includes('REPO must be specified') 85 | ).toBeTruthy(); 86 | } catch (e) { 87 | console.log(err); 88 | throw e; 89 | } 90 | }); 91 | }); 92 | 93 | it('missing-folder', async () => { 94 | const folders = await prepareTestFolders({ __filename }); 95 | 96 | // Run Action 97 | await util 98 | .runWithGithubEnv( 99 | folders.testName, 100 | { 101 | REPO: folders.repoUrl, 102 | BRANCH: 'branch-a', 103 | }, 104 | 's0/test', 105 | {}, 106 | 's0', 107 | { 108 | captureOutput: true, 109 | } 110 | ) 111 | .then(() => { 112 | throw new Error('Expected error'); 113 | }) 114 | .catch((err: util.TestRunError) => { 115 | try { 116 | expect(err.output).toBeDefined(); 117 | expect( 118 | err.output?.stderr.includes('FOLDER must be specified') 119 | ).toBeTruthy(); 120 | } catch (e) { 121 | console.log(err); 122 | throw e; 123 | } 124 | }); 125 | }); 126 | 127 | it('missing-branch', async () => { 128 | const folders = await prepareTestFolders({ __filename }); 129 | 130 | // Run Action 131 | await util 132 | .runWithGithubEnv( 133 | folders.testName, 134 | { 135 | REPO: folders.repoUrl, 136 | FOLDER: folders.dataDir, 137 | }, 138 | 's0/test', 139 | {}, 140 | 's0', 141 | { 142 | captureOutput: true, 143 | } 144 | ) 145 | .then(() => { 146 | throw new Error('Expected error'); 147 | }) 148 | .catch((err: util.TestRunError) => { 149 | try { 150 | expect(err.output).toBeDefined(); 151 | expect( 152 | err.output?.stderr.includes('BRANCH must be specified') 153 | ).toBeTruthy(); 154 | } catch (e) { 155 | console.log(err); 156 | throw e; 157 | } 158 | }); 159 | }); 160 | 161 | it('missing-event-path', async () => { 162 | const folders = await prepareTestFolders({ __filename }); 163 | 164 | // Run Action 165 | await util 166 | .runWithGithubEnv( 167 | folders.testName, 168 | { 169 | REPO: folders.repoUrl, 170 | BRANCH: 'branch-a', 171 | FOLDER: folders.dataDir, 172 | SSH_PRIVATE_KEY: ( 173 | await util.readFile(util.SSH_PRIVATE_KEY) 174 | ).toString(), 175 | }, 176 | 's0/test', 177 | {}, 178 | 's0', 179 | { 180 | captureOutput: true, 181 | excludeEventPath: true, 182 | } 183 | ) 184 | .then(() => { 185 | throw new Error('Expected error'); 186 | }) 187 | .catch((err: util.TestRunError) => { 188 | try { 189 | expect(err.output).toBeDefined(); 190 | expect( 191 | err.output?.stderr.includes('Expected GITHUB_EVENT_PATH') 192 | ).toBeTruthy(); 193 | } catch (e) { 194 | console.log(err); 195 | throw e; 196 | } 197 | }); 198 | }); 199 | 200 | it('missing-ssh-private-key', async () => { 201 | const folders = await prepareTestFolders({ __filename }); 202 | 203 | // Run Action 204 | await util 205 | .runWithGithubEnv( 206 | folders.testName, 207 | { 208 | REPO: folders.repoUrl, 209 | BRANCH: 'branch-a', 210 | FOLDER: folders.dataDir, 211 | }, 212 | 's0/test', 213 | {}, 214 | 's0', 215 | { 216 | captureOutput: true, 217 | } 218 | ) 219 | .then(() => { 220 | throw new Error('Expected error'); 221 | }) 222 | .catch((err: util.TestRunError) => { 223 | try { 224 | expect(err.output).toBeDefined(); 225 | expect( 226 | err.output?.stderr.includes( 227 | 'SSH_PRIVATE_KEY must be specified when REPO uses ssh' 228 | ) 229 | ).toBeTruthy(); 230 | } catch (e) { 231 | console.log(err); 232 | throw e; 233 | } 234 | }); 235 | }); 236 | 237 | it('unsupported-http-repo', async () => { 238 | const folders = await prepareTestFolders({ __filename }); 239 | 240 | // Run Action 241 | await util 242 | .runWithGithubEnv( 243 | folders.testName, 244 | { 245 | REPO: 'https://github.com/s0/git-publish-subdir-action-tests.git', 246 | BRANCH: 'branch-a', 247 | FOLDER: folders.dataDir, 248 | }, 249 | 's0/test', 250 | {}, 251 | 's0', 252 | { 253 | captureOutput: true, 254 | } 255 | ) 256 | .then(() => { 257 | throw new Error('Expected error'); 258 | }) 259 | .catch((err: util.TestRunError) => { 260 | try { 261 | expect(err.output).toBeDefined(); 262 | expect( 263 | err.output?.stderr.includes('Unsupported REPO URL') 264 | ).toBeTruthy(); 265 | } catch (e) { 266 | console.log(err); 267 | throw e; 268 | } 269 | }); 270 | }); 271 | it('unauthorized-ssh-key', async () => { 272 | const folders = await prepareTestFolders({ __filename }); 273 | 274 | // Run Action 275 | await util 276 | .runWithGithubEnv( 277 | folders.testName, 278 | { 279 | REPO: folders.repoUrl, 280 | BRANCH: 'branch-a', 281 | FOLDER: folders.dataDir, 282 | SSH_PRIVATE_KEY: ( 283 | await util.readFile(util.SSH_PRIVATE_KEY_INVALID) 284 | ).toString(), 285 | KNOWN_HOSTS_FILE: util.KNOWN_HOSTS, 286 | }, 287 | 's0/test', 288 | {}, 289 | 's0', 290 | { 291 | captureOutput: true, 292 | } 293 | ) 294 | .then(() => { 295 | throw new Error('Expected error'); 296 | }) 297 | .catch((err: util.TestRunError) => { 298 | try { 299 | expect(err.output).toBeDefined(); 300 | expect(err.output?.stderr.includes(SSH_KEY_ERROR)).toBeTruthy(); 301 | } catch (e) { 302 | console.log(err); 303 | throw e; 304 | } 305 | }); 306 | }); 307 | it('self-missing-token', async () => { 308 | const folders = await prepareTestFolders({ __filename }); 309 | 310 | // Run Action 311 | await util 312 | .runWithGithubEnv( 313 | folders.testName, 314 | { 315 | REPO: 'self', 316 | BRANCH: 'tmp-test-branch', 317 | FOLDER: folders.dataDir, 318 | }, 319 | 's0/test', 320 | {}, 321 | 's0', 322 | { 323 | captureOutput: true, 324 | } 325 | ) 326 | .then(() => { 327 | throw new Error('Expected error'); 328 | }) 329 | .catch((err: util.TestRunError) => { 330 | try { 331 | expect(err.output).toBeDefined(); 332 | expect( 333 | err.output?.stderr.includes( 334 | 'GITHUB_TOKEN must be specified when REPO == self' 335 | ) 336 | ).toBeTruthy(); 337 | } catch (e) { 338 | console.log(err); 339 | throw e; 340 | } 341 | }); 342 | }); 343 | 344 | it('self-missing-repo', async () => { 345 | const folders = await prepareTestFolders({ __filename }); 346 | 347 | // Run Action 348 | await util 349 | .runWithGithubEnv( 350 | folders.testName, 351 | { 352 | REPO: 'self', 353 | BRANCH: 'tmp-test-branch', 354 | FOLDER: folders.dataDir, 355 | GITHUB_TOKEN: 'foobar', 356 | }, 357 | undefined, 358 | {}, 359 | 's0', 360 | { 361 | captureOutput: true, 362 | } 363 | ) 364 | .then(() => { 365 | throw new Error('Expected error'); 366 | }) 367 | .catch((err: util.TestRunError) => { 368 | try { 369 | expect(err.output).toBeDefined(); 370 | expect( 371 | err.output?.stderr.includes( 372 | 'GITHUB_REPOSITORY must be specified when REPO == self' 373 | ) 374 | ).toBeTruthy(); 375 | } catch (e) { 376 | console.log(err); 377 | throw e; 378 | } 379 | }); 380 | }); 381 | }); 382 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Action: Push git subdirectory as branch 2 | 3 | [![](https://github.com/s0/git-publish-subdir-action/workflows/Scheduled%20tests/badge.svg)](https://github.com/s0/git-publish-subdir-action/actions?workflow=Scheduled+tests) [![codecov](https://codecov.io/gh/s0/git-publish-subdir-action/branch/master/graph/badge.svg)](https://codecov.io/gh/s0/git-publish-subdir-action) [![](https://raw.githubusercontent.com/s0/git-publish-subdir-action/gh-badges/drift.svg)](https://github.com/s0/libyear-node-action) [![](https://raw.githubusercontent.com/s0/git-publish-subdir-action/gh-badges/releases.svg)](https://github.com/s0/libyear-node-action) 4 | 5 | This GitHub Action will take any subdirectory in your repository, and push it as the contents of a git branch to a repository and branch of your choosing, either over SSH or to the current repo. 6 | 7 | You could use this for example to: 8 | 9 | * Publishing a subdirectory to a repo's `gh-pages` branch, after optionally running a build step. 10 | * Publishing build artifacts / binaries to another repository 11 | 12 | The target repository can be anywhere accessible by a [Git SSH URL](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_ssh_protocol) (or the current repository). 13 | If the target branch doesn't exist yet, it will be created automatically. 14 | 15 | ## Usage 16 | 17 | Simply include the action `s0/git-publish-subdir-action@develop` in the appropriate point in your workflow, and pass in the required configuration options: 18 | 19 | ```yml 20 | jobs: 21 | deploy: 22 | name: Deploy 23 | runs-on: ubuntu-latest 24 | steps: 25 | 26 | # Any prerequisite steps 27 | - uses: actions/checkout@master 28 | 29 | # Deploy to local repo 30 | - name: Deploy 31 | uses: s0/git-publish-subdir-action@develop 32 | env: 33 | REPO: self 34 | BRANCH: gh-pages 35 | FOLDER: build 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | # Deploy to another repo 39 | - name: Deploy 40 | uses: s0/git-publish-subdir-action@develop 41 | env: 42 | REPO: git@github.com:owner/repo.git 43 | BRANCH: gh-pages 44 | FOLDER: build 45 | SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_PRIVATE_KEY }} 46 | KNOWN_HOSTS_FILE: resources/known_hosts # Needed if target repo is not on github.com 47 | ``` 48 | 49 | ## Examples 50 | 51 | ### When pushed to master, push `/public/site` to the `gh-pages` branch on the same repo 52 | 53 | ```yml 54 | name: Deploy to GitHub Pages 55 | on: 56 | push: 57 | branches: 58 | - master 59 | 60 | jobs: 61 | deploy: 62 | name: Deploy to GitHub Pages 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@master 66 | 67 | - name: Deploy 68 | uses: s0/git-publish-subdir-action@develop 69 | env: 70 | REPO: self 71 | BRANCH: gh-pages 72 | FOLDER: public/site 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | ``` 75 | 76 | 77 | ### When pushed to master, push the contents of `/public/site` to the `www` folder on the `gh-pages` branch on the same repo 78 | 79 | ```yml 80 | name: Deploy to GitHub Pages 81 | on: 82 | push: 83 | branches: 84 | - master 85 | 86 | jobs: 87 | deploy: 88 | name: Deploy to GitHub Pages 89 | runs-on: ubuntu-latest 90 | steps: 91 | - uses: actions/checkout@master 92 | 93 | - name: Deploy 94 | uses: s0/git-publish-subdir-action@develop 95 | env: 96 | REPO: self 97 | BRANCH: gh-pages 98 | FOLDER: public/site 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | TARGET_DIR: www 101 | ``` 102 | 103 | ### When pushed to master, run a build step, then push `/build` to the `gh-pages` branch on another repo on GitHub 104 | 105 | ```yml 106 | name: Deploy to GitHub Pages 107 | on: 108 | push: 109 | branches: 110 | - master 111 | 112 | jobs: 113 | deploy: 114 | name: Deploy to GitHub Pages 115 | runs-on: ubuntu-latest 116 | steps: 117 | - uses: actions/checkout@master 118 | - name: Use Node.js 119 | uses: actions/setup-node@main 120 | with: 121 | node-version: 20.x 122 | - name: npm install and build 123 | run: | 124 | npm install 125 | npm run build 126 | 127 | - name: Deploy 128 | uses: s0/git-publish-subdir-action@develop 129 | env: 130 | REPO: git@github.com:owner/repo.git 131 | BRANCH: gh-pages 132 | FOLDER: build 133 | SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_PRIVATE_KEY }} 134 | ``` 135 | 136 | Note: the SSH Key needs to have write access to the given repo. It's recommended you use [Deploy Keys](https://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys) for this (see below), 137 | and store the SSH private key as as Secret in the repository settings. 138 | 139 | 140 | ### When pushed to master, run a build step, then push `/dist` to the `artifacts` branch on a repo hosted at mydomain.com 141 | 142 | ```yml 143 | name: Deploy to GitHub Pages 144 | on: 145 | push: 146 | branches: 147 | - master 148 | 149 | jobs: 150 | deploy: 151 | name: Deploy to GitHub Pages 152 | runs-on: ubuntu-latest 153 | steps: 154 | - uses: actions/checkout@master 155 | - name: Use Node.js 156 | uses: actions/setup-node@main 157 | with: 158 | node-version: 20.x 159 | - name: npm install and build 160 | run: | 161 | npm install 162 | npm run build 163 | 164 | - name: Deploy 165 | uses: s0/git-publish-subdir-action@develop 166 | env: 167 | REPO: git@mydomain.com:path/to/repo.git 168 | BRANCH: artifacts 169 | FOLDER: dist 170 | SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_PRIVATE_KEY }} 171 | KNOWN_HOSTS_FILE: resources/known_hosts # Path relative to the root of the repository 172 | ``` 173 | 174 | You can generate a `known_hosts` file for a given domain by using `ssh-keyscan`, e.g: 175 | 176 | ```bash 177 | > ssh-keyscan github.com 178 | # github.com:22 SSH-2.0-babeld-f345ed5d 179 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 180 | # github.com:22 SSH-2.0-babeld-f345ed5d 181 | # github.com:22 SSH-2.0-babeld-f345ed5d 182 | ``` 183 | 184 | 185 | ## Configuration 186 | 187 | All configuration options are passed in via `env`, as environment variables. 188 | 189 | | Env Variable | Description | Required? | 190 | | ------------------ | ------------------------------------------------------ | ------------- | 191 | | `REPO` | Either `self`, or an SSH url to the target repository. | Yes | 192 | | `BRANCH` | The target branch to publish to. | Yes | 193 | | `FOLDER` | The target subfolder you would like to publish | Yes | 194 | | `SSH_PRIVATE_KEY` | The private key that should be used to authenticate on SSH. Don't include this directly in the workflow file, instead you must use [Secrets](https://help.github.com/en/articles/virtual-environments-for-github-actions#creating-and-using-secrets-encrypted-variables) | When `REPO` is an SSH URL | 195 | | `KNOWN_HOSTS_FILE` | Path to a file in the repository that contains the known SSH fingerprints for the target host. | When the target host is not github.com | 196 | | `GITHUB_TOKEN` | Should always be equal to `${{ secrets.GITHUB_TOKEN }}` | When `REPO = self` | 197 | | `SQUASH_HISTORY` | If set to `true`, all previous commits on the target branch will be discarded. For example, if you are deploying a static site with lots of binary artifacts, this can help the repository becoming overly bloated. | No | 198 | | `SKIP_EMPTY_COMMITS` | If set to `true`, commits will only be pushed if the contents of the target branch will be changed as a result. This is useful if, for example, you'd like to easily track which upstream changes result in changes to your target branch. | No | 199 | | `MESSAGE` | A custom template to use as the commit message pushed to the target branch. See [custom commit messages](#custom-commit-messages). | No | 200 | | `TAG` | A string following the [git-check-ref-format](https://git-scm.com/docs/git-check-ref-format) that tags the commit with a lightweight git-tag. | No | 201 | | `CLEAR_GLOBS_FILE` | An optional path to a file to use as a list of globs defining which files to delete when clearing the target branch. | No | 202 | | `COMMIT_NAME` | The username the autogenerated commit will use. If unset, uses the commit pusher's username. | No | 203 | | `COMMIT_EMAIL` | The email the autogenerated commit will use. If unset, uses the commit pusher's email. | No | 204 | | `TARGET_DIR` | An optional string to change the directory where the files are copied to. | No | 205 | 206 | 207 | ### Custom commit messages 208 | 209 | You can specify a custom string to use in the commit message 210 | when pushing to your target repository. 211 | These strings support a number of placeholders that will be replaces with 212 | relevant values: 213 | 214 | | Placeholder | Description | 215 | | ------------------ | ----------------------------------------------------- | 216 | | `{target-branch}` | The name of the target branch being updated | 217 | | `{sha}` | The 7-character sha of the HEAD of the current branch | 218 | | `{long-sha}` | The full sha of the HEAD of the current branch | 219 | | `{msg}` | The commit message for the HEAD of the current branch | 220 | 221 | Example Usage: 222 | 223 | ```yml 224 | jobs: 225 | deploy: 226 | - uses: s0/git-publish-subdir-action@develop 227 | env: 228 | # ... 229 | MESSAGE: "This updates the content to the commit {sha} that had the message:\n{msg}" 230 | ``` 231 | 232 | ### Custom clear operations 233 | 234 | By default, this action will clear the target branch of any pre-existing files, 235 | and only keep those that are defined in the target `FOLDER` when the action was 236 | run. 237 | 238 | This can now be overwritten by specifying a file with a custom list of globs to 239 | define which files should be deleted from the target branch before copying the 240 | new files over. 241 | 242 | The environment variable `CLEAR_GLOBS_FILE` should point to the path of the 243 | glob file (which can have any name) relative to root of the target repository. 244 | 245 | **Note: using this feature will disable the default functionality of deleting 246 | everything, and you will need to specify exactly what needs to be deleted.** 247 | 248 | #### Examples 249 | 250 | 1. Default behaviour: 251 | 252 | ``` 253 | **/* 254 | !.git 255 | ``` 256 | 257 | 1. Default behaviour with a custom target directory: 258 | 259 | ``` 260 | target_dir/**/* 261 | !.git 262 | ``` 263 | 264 | 1. Delete everything except the `.git` and `foobar` folder: 265 | 266 | ``` 267 | **/* 268 | !.git 269 | !foobar/**/* 270 | ``` 271 | 272 | 1. Only delete the folder `folder` (except `folder/a`), and also delete anything 273 | matching `ini*al2`: 274 | 275 | ``` 276 | folder/* 277 | !folder/a 278 | ini*al2 279 | ``` 280 | 281 | For clarity, if we have the file `.clear-target-files`: 282 | 283 | ``` 284 | folder/* 285 | !folder/a 286 | ini*al2 287 | ``` 288 | 289 | And the workflow file `.github/workflows/ci.yml`: 290 | 291 | ```yml 292 | jobs: 293 | deploy: 294 | - uses: s0/git-publish-subdir-action@develop 295 | env: 296 | # ... 297 | CLEAR_GLOBS_FILE: ".clear-target-files" 298 | ``` 299 | 300 | And the target branch already had the files: 301 | 302 | ``` 303 | initial1 304 | initial2 305 | folder/a 306 | folder/b 307 | ``` 308 | 309 | Then the files that would remain would be: 310 | 311 | ``` 312 | folder/a 313 | initial1 314 | ``` 315 | 316 | An empty file can be used to indicate that the branch should not be cleared at 317 | all. 318 | 319 | 320 | 321 | ## Usage with [Deploy Keys](https://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys) 322 | 323 | When pushing to other repositories on GitHub or GitHub Enterprise, 324 | the recommended mechanism is to use [Deploy Keys](https://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys) rather than your own private SSH key. 325 | Deploy keys are SSH keys that can be added to specific repositories to be given write access to only 326 | those repositories. 327 | This is preferable to adding your own personal ssh private key to a repository's secrets store, 328 | as it means that any actions that have access to this repository's secrets can only push 329 | to repositories that have explicitly had the SSH key added as a deploy key, 330 | and not *all* repositories that your user account have access to. 331 | 332 | Use `ssh-keygen` to create a new ssh key, add these to GitHub in the relevant repo's deploy keys and Secrets, then delete them off your computer. 333 | 334 | ``` 335 | > cd /tmp 336 | > ssh-keygen -t ed25519 337 | Generating public/private ed25519 key pair. 338 | Enter file in which to save the key (/home/sam/.ssh/id_ed25519): temp-deploy-key 339 | Enter passphrase (empty for no passphrase): 340 | Enter same passphrase again: 341 | Your identification has been saved in temp-deploy-key. 342 | Your public key has been saved in temp-deploy-key.pub. 343 | The key fingerprint is: 344 | SHA256:tQBSeWjZ4Er4YYjK4XQ4npfiK2xJPJLbGjTsYJq/9JI sam@optimus 345 | The key's randomart image is: 346 | +--[ED25519 256]--+ 347 | | ..+* | 348 | | ..o o=.o | 349 | |.=o.+.... . | 350 | |B =+.o o . | 351 | |+% oo S . | 352 | |X=+ | 353 | |B=+. | 354 | |oBE. | 355 | |+ooo. | 356 | +----[SHA256]-----+ 357 | > cat temp-deploy-key.pub 358 | ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX user@localhost 359 | > cat temp-deploy-key 360 | -----BEGIN OPENSSH PRIVATE KEY----- 361 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 362 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 363 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 364 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 365 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= 366 | -----END OPENSSH PRIVATE KEY----- 367 | ``` 368 | -------------------------------------------------------------------------------- /action/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from 'child_process'; 2 | import { stream as fgStream } from 'fast-glob'; 3 | import fsModule, { promises as fs } from 'fs'; 4 | import gitUrlParse from 'git-url-parse'; 5 | import { homedir, tmpdir } from 'os'; 6 | import * as path from 'path'; 7 | import git from 'isomorphic-git'; 8 | import { mkdirP, cp } from '@actions/io'; 9 | 10 | export type Console = { 11 | readonly log: (...msg: unknown[]) => void; 12 | readonly error: (...msg: unknown[]) => void; 13 | readonly warn: (...msg: unknown[]) => void; 14 | }; 15 | 16 | /** 17 | * Custom wrapper around the child_process module 18 | */ 19 | export const exec = async ( 20 | cmd: string, 21 | opts: { 22 | env?: any; 23 | cwd?: string; 24 | log: Console; 25 | } 26 | ) => { 27 | const { log } = opts; 28 | const env = opts?.env || {}; 29 | const ps = child_process.spawn('bash', ['-c', cmd], { 30 | env: { 31 | HOME: process.env.HOME, 32 | ...env, 33 | }, 34 | cwd: opts.cwd, 35 | stdio: ['pipe', 'pipe', 'pipe'], 36 | }); 37 | 38 | const output = { 39 | stderr: '', 40 | stdout: '', 41 | }; 42 | 43 | // We won't be providing any input to command 44 | ps.stdin.end(); 45 | ps.stdout.on('data', (data) => { 46 | output.stdout += data; 47 | log.log(`data`, data.toString()); 48 | }); 49 | ps.stderr.on('data', (data) => { 50 | output.stderr += data; 51 | log.error(data.toString()); 52 | }); 53 | 54 | return new Promise<{ 55 | stderr: string; 56 | stdout: string; 57 | }>((resolve, reject) => 58 | ps.on('close', (code) => { 59 | if (code !== 0) { 60 | reject( 61 | new Error('Process exited with code: ' + code + ':\n' + output.stderr) 62 | ); 63 | } else { 64 | resolve(output); 65 | } 66 | }) 67 | ); 68 | }; 69 | 70 | export interface EnvironmentVariables { 71 | /** 72 | * The URL of the repository to push to, either: 73 | * 74 | * * an ssh URL to a repository 75 | * * the string `"self"` 76 | */ 77 | REPO?: string; 78 | /** 79 | * The name of the branch to push to 80 | */ 81 | BRANCH?: string; 82 | /** 83 | * Which subdirectory in the repository to we want to push as the contents of the branch 84 | */ 85 | FOLDER?: string; 86 | /** 87 | * The private key to use for publishing if REPO is an SSH repo 88 | */ 89 | SSH_PRIVATE_KEY?: string; 90 | /** 91 | * The file path of a known_hosts file with fingerprint of the relevant server 92 | */ 93 | KNOWN_HOSTS_FILE?: string; 94 | /** 95 | * The GITHUB_TOKEN secret 96 | */ 97 | GITHUB_TOKEN?: string; 98 | /** 99 | * Set to "true" to clear all of the history of the target branch and force push 100 | */ 101 | SQUASH_HISTORY?: string; 102 | /** 103 | * Set to "true" to avoid pushing commits that don't change any files. 104 | * 105 | * This is useful for example when you want to be able to easily identify 106 | * which upstream changes resulted in changes to this repository. 107 | */ 108 | SKIP_EMPTY_COMMITS?: string; 109 | /** 110 | * An optional template string to use for the commit message, 111 | * if not provided, a default template is used. 112 | * 113 | * A number of placeholders are available to use in template strings: 114 | * * `{target-branch}` - the name of the target branch being updated 115 | * * `{sha}` - the 7-character sha of the HEAD of the current branch 116 | * * `{long-sha}` - the full sha of the HEAD of the current branch 117 | * * `{msg}` - the commit message for the HEAD of the current branch 118 | */ 119 | MESSAGE?: string; 120 | /** 121 | * An optional path to a file to use as a list of globs defining which files 122 | * to delete when clearing the target branch 123 | */ 124 | CLEAR_GLOBS_FILE?: string; 125 | /** 126 | * An optional string in git-check-ref-format to use for tagging the commit 127 | */ 128 | TAG?: string; 129 | 130 | /** 131 | * An optional string to use as the commiter name on the git commit. 132 | */ 133 | COMMIT_NAME?: string; 134 | 135 | /** 136 | * An optional string to use as the commiter email on the git commit. 137 | */ 138 | COMMIT_EMAIL?: string; 139 | 140 | // Implicit environment variables passed by GitHub 141 | 142 | GITHUB_REPOSITORY?: string; 143 | GITHUB_EVENT_PATH?: string; 144 | /** The name of the person / app that that initiated the workflow */ 145 | GITHUB_ACTOR?: string; 146 | /** 147 | * An optional string to change the directory where the files are copied to 148 | */ 149 | TARGET_DIR?: string; 150 | } 151 | 152 | declare global { 153 | namespace NodeJS { 154 | interface ProcessEnv extends EnvironmentVariables {} 155 | } 156 | } 157 | 158 | const DEFAULT_MESSAGE = 'Update {target-branch} to output generated at {sha}'; 159 | 160 | // Error messages 161 | 162 | const KNOWN_HOSTS_WARNING = ` 163 | ##[warning] KNOWN_HOSTS_FILE not set 164 | This will probably mean that host verification will fail later on 165 | `; 166 | 167 | const KNOWN_HOSTS_ERROR = (host: string) => ` 168 | ##[error] Host key verification failed! 169 | This is probably because you forgot to supply a value for KNOWN_HOSTS_FILE 170 | or the file is invalid or doesn't correctly verify the host ${host} 171 | `; 172 | 173 | const SSH_KEY_ERROR = ` 174 | ##[error] Permission denied (publickey) 175 | Make sure that the ssh private key is set correctly, and 176 | that the public key has been added to the target repo 177 | `; 178 | 179 | const INVALID_KEY_ERROR = ` 180 | ##[error] Error loading key: invalid format 181 | Please check that you're setting the environment variable 182 | SSH_PRIVATE_KEY correctly 183 | `; 184 | 185 | // Paths 186 | 187 | const REPO_SELF = 'self'; 188 | const RESOURCES = path.join(path.dirname(__dirname), 'resources'); 189 | const KNOWN_HOSTS_GITHUB = path.join(RESOURCES, 'known_hosts_github.com'); 190 | const SSH_FOLDER = path.join(homedir(), '.ssh'); 191 | const KNOWN_HOSTS_TARGET = path.join(SSH_FOLDER, 'known_hosts'); 192 | 193 | const SSH_AGENT_PID_EXTRACT = /SSH_AGENT_PID=([0-9]+);/; 194 | 195 | interface BaseConfig { 196 | branch: string; 197 | folder: string; 198 | repo: string; 199 | squashHistory: boolean; 200 | skipEmptyCommits: boolean; 201 | message: string; 202 | tag?: string; 203 | } 204 | 205 | interface SshConfig extends BaseConfig { 206 | mode: 'ssh'; 207 | parsedUrl: gitUrlParse.GitUrl; 208 | privateKey: string; 209 | knownHostsFile?: string; 210 | } 211 | 212 | interface SelfConfig extends BaseConfig { 213 | mode: 'self'; 214 | } 215 | 216 | type Config = SshConfig | SelfConfig; 217 | 218 | /** 219 | * The GitHub event that triggered this action 220 | */ 221 | export interface Event { 222 | pusher?: { 223 | email?: string; 224 | name?: string; 225 | }; 226 | } 227 | 228 | const genConfig: (env?: EnvironmentVariables) => Config = ( 229 | env = process.env 230 | ) => { 231 | if (!env.REPO) throw new Error('REPO must be specified'); 232 | if (!env.BRANCH) throw new Error('BRANCH must be specified'); 233 | if (!env.FOLDER) throw new Error('FOLDER must be specified'); 234 | 235 | const repo = env.REPO; 236 | const branch = env.BRANCH; 237 | const folder = env.FOLDER; 238 | const squashHistory = env.SQUASH_HISTORY === 'true'; 239 | const skipEmptyCommits = env.SKIP_EMPTY_COMMITS === 'true'; 240 | const message = env.MESSAGE || DEFAULT_MESSAGE; 241 | const tag = env.TAG; 242 | 243 | // Determine the type of URL 244 | if (repo === REPO_SELF) { 245 | if (!env.GITHUB_TOKEN) 246 | throw new Error('GITHUB_TOKEN must be specified when REPO == self'); 247 | if (!env.GITHUB_REPOSITORY) 248 | throw new Error('GITHUB_REPOSITORY must be specified when REPO == self'); 249 | const url = `https://x-access-token:${env.GITHUB_TOKEN}@github.com/${env.GITHUB_REPOSITORY}.git`; 250 | const config: Config = { 251 | repo: url, 252 | branch, 253 | folder, 254 | squashHistory, 255 | skipEmptyCommits, 256 | mode: 'self', 257 | message, 258 | tag, 259 | }; 260 | return config; 261 | } 262 | const parsedUrl = gitUrlParse(repo); 263 | 264 | if (parsedUrl.protocol === 'ssh') { 265 | if (!env.SSH_PRIVATE_KEY) 266 | throw new Error('SSH_PRIVATE_KEY must be specified when REPO uses ssh'); 267 | const config: Config = { 268 | repo, 269 | branch, 270 | folder, 271 | squashHistory, 272 | skipEmptyCommits, 273 | mode: 'ssh', 274 | parsedUrl, 275 | privateKey: env.SSH_PRIVATE_KEY, 276 | knownHostsFile: env.KNOWN_HOSTS_FILE, 277 | message, 278 | tag, 279 | }; 280 | return config; 281 | } 282 | throw new Error('Unsupported REPO URL'); 283 | }; 284 | 285 | const writeToProcess = ( 286 | command: string, 287 | args: string[], 288 | opts: { 289 | env: { [id: string]: string | undefined }; 290 | data: string; 291 | log: Console; 292 | } 293 | ) => 294 | new Promise((resolve, reject) => { 295 | const child = child_process.spawn(command, args, { 296 | env: opts.env, 297 | stdio: 'pipe', 298 | }); 299 | child.stdin.setDefaultEncoding('utf-8'); 300 | child.stdin.write(opts.data); 301 | child.stdin.end(); 302 | child.on('error', reject); 303 | let stderr = ''; 304 | child.stdout.on('data', (data) => { 305 | /* istanbul ignore next */ 306 | opts.log.log(data.toString()); 307 | }); 308 | child.stderr.on('data', (data) => { 309 | stderr += data; 310 | opts.log.error(data.toString()); 311 | }); 312 | child.on('close', (code) => { 313 | /* istanbul ignore else */ 314 | if (code === 0) { 315 | resolve(); 316 | } else { 317 | reject(new Error(stderr)); 318 | } 319 | }); 320 | }); 321 | 322 | export const main = async ({ 323 | env = process.env, 324 | log, 325 | }: { 326 | env?: EnvironmentVariables; 327 | log: Console; 328 | }) => { 329 | const config = genConfig(env); 330 | 331 | // Calculate paths that use temp diractory 332 | 333 | const TMP_PATH = await fs.mkdtemp( 334 | path.join(tmpdir(), 'git-publish-subdir-action-') 335 | ); 336 | const REPO_TEMP = path.join(TMP_PATH, 'repo'); 337 | const SSH_AUTH_SOCK = path.join(TMP_PATH, 'ssh_agent.sock'); 338 | 339 | if (!env.GITHUB_EVENT_PATH) throw new Error('Expected GITHUB_EVENT_PATH'); 340 | 341 | const event: Event = JSON.parse( 342 | (await fs.readFile(env.GITHUB_EVENT_PATH)).toString() 343 | ); 344 | 345 | const name = 346 | env.COMMIT_NAME || 347 | event.pusher?.name || 348 | env.GITHUB_ACTOR || 349 | 'Git Publish Subdirectory'; 350 | const email = 351 | env.COMMIT_EMAIL || 352 | event.pusher?.email || 353 | (env.GITHUB_ACTOR 354 | ? `${env.GITHUB_ACTOR}@users.noreply.github.com` 355 | : 'nobody@nowhere'); 356 | const tag = env.TAG; 357 | 358 | // Set Git Config 359 | await exec(`git config --global user.name "${name}"`, { log }); 360 | await exec(`git config --global user.email "${email}"`, { log }); 361 | 362 | interface GitInformation { 363 | commitMessage: string; 364 | sha: string; 365 | } 366 | 367 | /** 368 | * Get information about the current git repository 369 | */ 370 | const getGitInformation = async (): Promise => { 371 | // Get the root git directory 372 | let dir = process.cwd(); 373 | while (true) { 374 | const isGitRepo = await fs 375 | .stat(path.join(dir, '.git')) 376 | .then((s) => s.isDirectory()) 377 | .catch(() => false); 378 | if (isGitRepo) { 379 | break; 380 | } 381 | // We need to traverse up one 382 | const next = path.dirname(dir); 383 | if (next === dir) { 384 | log.log( 385 | `##[info] Not running in git directory, unable to get information about source commit` 386 | ); 387 | return { 388 | commitMessage: '', 389 | sha: '', 390 | }; 391 | } else { 392 | dir = next; 393 | } 394 | } 395 | 396 | // Get current sha of repo to use in commit message 397 | const gitLog = await git.log({ 398 | fs: fsModule, 399 | depth: 1, 400 | dir, 401 | }); 402 | const commit = gitLog.length > 0 ? gitLog[0] : undefined; 403 | if (!commit) { 404 | log.log(`##[info] Unable to get information about HEAD commit`); 405 | return { 406 | commitMessage: '', 407 | sha: '', 408 | }; 409 | } 410 | return { 411 | // Use trim to remove the trailing newline 412 | commitMessage: commit.commit.message.trim(), 413 | sha: commit.oid, 414 | }; 415 | }; 416 | 417 | const gitInfo = await getGitInformation(); 418 | 419 | // Environment to pass to children 420 | const childEnv = Object.assign({}, process.env, { 421 | SSH_AUTH_SOCK, 422 | }); 423 | 424 | if (config.mode === 'ssh') { 425 | // Copy over the known_hosts file if set 426 | let known_hosts = config.knownHostsFile; 427 | // Use well-known known_hosts for certain domains 428 | if (!known_hosts && config.parsedUrl.resource === 'github.com') { 429 | known_hosts = KNOWN_HOSTS_GITHUB; 430 | } 431 | if (!known_hosts) { 432 | log.warn(KNOWN_HOSTS_WARNING); 433 | } else { 434 | await mkdirP(SSH_FOLDER); 435 | await fs.copyFile(known_hosts, KNOWN_HOSTS_TARGET); 436 | } 437 | 438 | // Setup ssh-agent with private key 439 | log.log(`Setting up ssh-agent on ${SSH_AUTH_SOCK}`); 440 | const sshAgentMatch = SSH_AGENT_PID_EXTRACT.exec( 441 | (await exec(`ssh-agent -a ${SSH_AUTH_SOCK}`, { log, env: childEnv })) 442 | .stdout 443 | ); 444 | /* istanbul ignore if */ 445 | if (!sshAgentMatch) throw new Error('Unexpected output from ssh-agent'); 446 | childEnv.SSH_AGENT_PID = sshAgentMatch[1]; 447 | log.log(`Adding private key to ssh-agent at ${SSH_AUTH_SOCK}`); 448 | await writeToProcess('ssh-add', ['-'], { 449 | data: config.privateKey + '\n', 450 | env: childEnv, 451 | log, 452 | }); 453 | log.log(`Private key added`); 454 | } 455 | 456 | // Clone the target repo 457 | await exec(`git clone "${config.repo}" "${REPO_TEMP}"`, { 458 | log, 459 | env: childEnv, 460 | }).catch((err) => { 461 | const s = err.toString(); 462 | /* istanbul ignore else */ 463 | if (config.mode === 'ssh') { 464 | /* istanbul ignore else */ 465 | if (s.indexOf('Host key verification failed') !== -1) { 466 | log.error(KNOWN_HOSTS_ERROR(config.parsedUrl.resource)); 467 | } else if (s.indexOf('Permission denied (publickey') !== -1) { 468 | log.error(SSH_KEY_ERROR); 469 | } 470 | } 471 | throw err; 472 | }); 473 | 474 | if (!config.squashHistory) { 475 | // Fetch branch if it exists 476 | await exec(`git fetch -u origin ${config.branch}:${config.branch}`, { 477 | log, 478 | env: childEnv, 479 | cwd: REPO_TEMP, 480 | }).catch((err) => { 481 | const s = err.toString(); 482 | /* istanbul ignore if */ 483 | if (s.indexOf("Couldn't find remote ref") === -1) { 484 | log.error( 485 | "##[warning] Failed to fetch target branch, probably doesn't exist" 486 | ); 487 | log.error(err); 488 | } 489 | }); 490 | 491 | // Check if branch already exists 492 | log.log(`##[info] Checking if branch ${config.branch} exists already`); 493 | const branchCheck = await exec(`git branch --list "${config.branch}"`, { 494 | log, 495 | env: childEnv, 496 | cwd: REPO_TEMP, 497 | }); 498 | if (branchCheck.stdout.trim() === '') { 499 | // Branch does not exist yet, let's check it out as an orphan 500 | log.log(`##[info] ${config.branch} does not exist, creating as orphan`); 501 | await exec(`git checkout --orphan "${config.branch}"`, { 502 | log, 503 | env: childEnv, 504 | cwd: REPO_TEMP, 505 | }); 506 | } else { 507 | await exec(`git checkout "${config.branch}"`, { 508 | log, 509 | env: childEnv, 510 | cwd: REPO_TEMP, 511 | }); 512 | } 513 | } else { 514 | // Checkout a random branch so we can delete the target branch if it exists 515 | log.log('Checking out temp branch'); 516 | await exec(`git checkout -b "${Math.random().toString(36).substring(2)}"`, { 517 | log, 518 | env: childEnv, 519 | cwd: REPO_TEMP, 520 | }); 521 | // Delete the target branch if it exists 522 | await exec(`git branch -D "${config.branch}"`, { 523 | log, 524 | env: childEnv, 525 | cwd: REPO_TEMP, 526 | }).catch((err) => {}); 527 | // Checkout target branch as an orphan 528 | await exec(`git checkout --orphan "${config.branch}"`, { 529 | log, 530 | env: childEnv, 531 | cwd: REPO_TEMP, 532 | }); 533 | log.log('Checked out orphan'); 534 | } 535 | 536 | // // Update contents of branch 537 | log.log(`##[info] Updating branch ${config.branch}`); 538 | 539 | /** 540 | * The list of globs we'll use for clearing 541 | */ 542 | const globs = await (async () => { 543 | if (env.CLEAR_GLOBS_FILE) { 544 | // We need to use a custom mechanism to clear the files 545 | log.log( 546 | `##[info] Using custom glob file to clear target branch ${env.CLEAR_GLOBS_FILE}` 547 | ); 548 | const globList = (await fs.readFile(env.CLEAR_GLOBS_FILE)) 549 | .toString() 550 | .split('\n') 551 | .map((s) => s.trim()) 552 | .filter((s) => s !== ''); 553 | return globList; 554 | } else if (env.TARGET_DIR) { 555 | log.log( 556 | `##[info] Removing all files from target dir ${env.TARGET_DIR} on target branch` 557 | ); 558 | return [`${env.TARGET_DIR}/**/*`, '!.git']; 559 | } else { 560 | // Remove all files 561 | log.log(`##[info] Removing all files from target branch`); 562 | return ['**/*', '!.git']; 563 | } 564 | })(); 565 | const filesToDelete = fgStream(globs, { 566 | absolute: true, 567 | dot: true, 568 | followSymbolicLinks: false, 569 | cwd: REPO_TEMP, 570 | }); 571 | // Delete all files from the filestream 572 | for await (const entry of filesToDelete) { 573 | await fs.unlink(entry); 574 | } 575 | const folder = path.resolve(process.cwd(), config.folder); 576 | const destinationFolder = env.TARGET_DIR ? env.TARGET_DIR : './'; 577 | 578 | // Make sure the destination folder exists 579 | await mkdirP(path.resolve(REPO_TEMP, destinationFolder)); 580 | 581 | log.log(`##[info] Copying all files from ${folder}`); 582 | await cp(`${folder}/`, `${REPO_TEMP}/${destinationFolder}/`, { 583 | recursive: true, 584 | copySourceDirectory: false, 585 | }); 586 | await exec(`git add -A .`, { log, env: childEnv, cwd: REPO_TEMP }); 587 | const message = config.message 588 | .replace(/\{target\-branch\}/g, config.branch) 589 | .replace(/\{sha\}/g, gitInfo.sha.substr(0, 7)) 590 | .replace(/\{long\-sha\}/g, gitInfo.sha) 591 | .replace(/\{msg\}/g, gitInfo.commitMessage); 592 | await git.commit({ 593 | fs: fsModule, 594 | dir: REPO_TEMP, 595 | message, 596 | author: { email, name }, 597 | }); 598 | if (tag) { 599 | log.log(`##[info] Tagging commit with ${tag}`); 600 | await git.tag({ 601 | fs: fsModule, 602 | dir: REPO_TEMP, 603 | ref: tag, 604 | force: true, 605 | }); 606 | } 607 | if (config.skipEmptyCommits) { 608 | log.log(`##[info] Checking whether contents have changed before pushing`); 609 | // Before we push, check whether it changed the tree, 610 | // and avoid pushing if not 611 | const head = await git.resolveRef({ 612 | fs: fsModule, 613 | dir: REPO_TEMP, 614 | ref: 'HEAD', 615 | }); 616 | const currentCommit = await git.readCommit({ 617 | fs: fsModule, 618 | dir: REPO_TEMP, 619 | oid: head, 620 | }); 621 | if (currentCommit.commit.parent.length === 1) { 622 | const previousCommit = await git.readCommit({ 623 | fs: fsModule, 624 | dir: REPO_TEMP, 625 | oid: currentCommit.commit.parent[0], 626 | }); 627 | if (currentCommit.commit.tree === previousCommit.commit.tree) { 628 | log.log(`##[info] Contents of target repo unchanged, exiting.`); 629 | return; 630 | } 631 | } 632 | } 633 | log.log(`##[info] Pushing`); 634 | const forceArg = config.squashHistory ? '-f' : ''; 635 | const tagsArg = tag ? '--tags' : ''; 636 | const push = await exec( 637 | `git push ${forceArg} origin "${config.branch}" ${tagsArg}`, 638 | { log, env: childEnv, cwd: REPO_TEMP } 639 | ); 640 | log.log(push.stdout); 641 | log.log(`##[info] Deployment Successful`); 642 | 643 | if (config.mode === 'ssh') { 644 | log.log(`##[info] Killing ssh-agent`); 645 | await exec(`ssh-agent -k`, { log, env: childEnv }); 646 | } 647 | 648 | log.log(`##[info] Removing temporary directory`); 649 | await fs.rm(TMP_PATH, { recursive: true }); 650 | }; 651 | --------------------------------------------------------------------------------