From f6407338d91e7ac88f9719d76124da8711ee1bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hahn?= Date: Mon, 13 Nov 2023 16:24:15 -0500 Subject: [PATCH 1/4] Implement deployToGhPages plugin --- ts/deployToGhPages.test.ts | 165 +++++++++++++++++++++++++++++++++++++ ts/deployToGhPages.ts | 73 ++++++++++++++++ ts/mod.ts | 1 + ts/testUtils.ts | 28 +++++-- 4 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 ts/deployToGhPages.test.ts create mode 100644 ts/deployToGhPages.ts diff --git a/ts/deployToGhPages.test.ts b/ts/deployToGhPages.test.ts new file mode 100644 index 00000000..d06b64f3 --- /dev/null +++ b/ts/deployToGhPages.test.ts @@ -0,0 +1,165 @@ +import { describe, it } from "https://deno.land/std@0.206.0/testing/bdd.ts"; +import { + assertEquals, + assertMatch, + assertNotEquals, +} from "https://deno.land/std@0.206.0/assert/mod.ts"; +import * as garn from "./mod.ts"; +import { assertSuccess, runCommand, runExecutable } from "./testUtils.ts"; +import outdent from "https://deno.land/x/outdent@v0.8.0/mod.ts"; + +const project = garn + .mkProject( + { + description: "", + defaultEnvironment: garn.emptyEnvironment, + }, + { + buildProject: garn.build`echo built > $out/artifact ; echo hidden > $out/.hidden`, + }, + ) + .add(garn.deployToGhPages((self) => self.buildProject)); + +const mkGitRepo = () => { + const path = Deno.makeTempDirSync(); + const run = (...args: Array) => { + const output = runCommand(new Deno.Command("git", { args, cwd: path })); + assertSuccess(output); + return output.stdout; + }; + run("init"); + run("commit", "--allow-empty", "-m", "first commit"); + return { run, path }; +}; + +const ansiRegexp = "\\x1b\\[[0-9;]+m"; + +describe("deployToGhPagesBranch", () => { + it("allows to create a commit on a new 'gh-pages' branch", () => { + const gitRepo = mkGitRepo(); + const output = runExecutable(project.deployToGhPages, { + cwd: gitRepo.path, + }); + assertSuccess(output); + assertMatch( + output.stderr, + regexp` + ^ + warning: creating lock file [^\\n]+\\n + (?: + this derivation will be built:\\n + [^\\n]+\\n + building[^\\n]+\\n + )? + Created commit to "gh-pages" branch, but it has not been pushed yet\\n + Run ${ansiRegexp}git push gh-pages:gh-pages${ansiRegexp} to deploy\\n + $ + `, + ); + gitRepo.run("checkout", "gh-pages"); + assertEquals( + Array.from(Deno.readDirSync(gitRepo.path)) + .map((x) => x.name) + .sort(), + [".git", ".hidden", "artifact"], + ); + assertEquals(Deno.readTextFileSync(`${gitRepo.path}/artifact`), "built\n"); + assertMatch( + gitRepo.run("log", "-n1", "--pretty=format:%s"), + /^Deploy [0-9a-f]{7} to gh-pages$/, + ); + assertEquals(gitRepo.run("status", "--short"), ""); + }); + + it("allows to create a commit on an existing 'gh-pages' branch", () => { + const gitRepo = mkGitRepo(); + gitRepo.run("checkout", "--orphan", "gh-pages"); + Deno.writeTextFileSync(`${gitRepo.path}/foo`, "some existing content"); + Deno.writeTextFileSync(`${gitRepo.path}/.hidden`, "some hidden content"); + gitRepo.run("add", "foo", ".hidden"); + gitRepo.run("commit", "-m", "Add some files"); + gitRepo.run("checkout", "master"); + assertSuccess( + runExecutable(project.deployToGhPages, { cwd: gitRepo.path }), + ); + gitRepo.run("checkout", "gh-pages"); + assertEquals( + Array.from(Deno.readDirSync(gitRepo.path)) + .map((x) => x.name) + .sort(), + [".git", ".hidden", "artifact"], + ); + assertEquals(Deno.readTextFileSync(`${gitRepo.path}/artifact`), "built\n"); + assertMatch( + gitRepo.run("log", "--pretty=format:%s"), + /^Deploy [0-9a-f]{7} to gh-pages\nAdd some files$/, + ); + assertEquals(gitRepo.run("status", "--short"), ""); + }); + + it("does not commit files from the source branch to the 'gh-pages' branch", () => { + const gitRepo = mkGitRepo(); + Deno.writeTextFileSync(`${gitRepo.path}/foo`, "some existing content"); + Deno.writeTextFileSync(`${gitRepo.path}/.hidden`, "some hidden content"); + gitRepo.run("add", "foo", ".hidden"); + gitRepo.run("commit", "-m", "Add some files"); + assertSuccess( + runExecutable(project.deployToGhPages, { cwd: gitRepo.path }), + ); + gitRepo.run("checkout", "gh-pages"); + assertEquals( + Array.from(Deno.readDirSync(gitRepo.path)) + .map((x) => x.name) + .sort(), + [".git", ".hidden", "artifact"], + ); + assertMatch( + gitRepo.run("log", "--pretty=format:%s"), + /^Deploy [0-9a-f]{7} to gh-pages$/, + ); + }); + + it("does not affect working-tree changes", () => { + const gitRepo = mkGitRepo(); + Deno.writeTextFileSync(`${gitRepo.path}/foo`, "some existing content"); + gitRepo.run("add", "foo"); + gitRepo.run("commit", "-m", "Add foo"); + Deno.writeTextFileSync(`${gitRepo.path}/foo`, "foo has been modified"); + Deno.writeTextFileSync( + `${gitRepo.path}/untracked`, + "some untracked content", + ); + assertSuccess( + runExecutable(project.deployToGhPages, { cwd: gitRepo.path }), + ); + assertEquals( + Array.from(Deno.readDirSync(gitRepo.path)) + .map((x) => x.name) + .sort(), + [".git", "foo", "untracked"], + ); + }); + + it("throws an error if the gh-pages branch is already checked out", () => { + const gitRepo = mkGitRepo(); + gitRepo.run("checkout", "-b", "gh-pages"); + const output = runExecutable(project.deployToGhPages, { + cwd: gitRepo.path, + }); + assertNotEquals(output.exitCode, 0); + assertMatch( + output.stderr, + regexp` + ^ + warning: creating lock file [^\\n]+\\n + ${ansiRegexp}error: + ${ansiRegexp} deployToGhPages cannot run if gh-pages is currently checked out. Please change branches first.${ansiRegexp}\\n + $ + `, + ); + }); +}); + +function regexp(f: TemplateStringsArray, ...templates: Array) { + return new RegExp(outdent(f, ...templates).replaceAll("\n", "")); +} diff --git a/ts/deployToGhPages.ts b/ts/deployToGhPages.ts new file mode 100644 index 00000000..448f52ce --- /dev/null +++ b/ts/deployToGhPages.ts @@ -0,0 +1,73 @@ +import { Executable } from "./executable.ts"; +import { Package } from "./package.ts"; +import { Plugin } from "./project.ts"; + +const ansiBold = "\\e[0;1m"; +const ansiReset = "\\e[0m"; +const ansiRedBold = "\\e[31;1m"; + +/** + * A garn plugin that allows easy deployment of a package to GitHub pages. + * + * @param pkg - The `Package` whose artifacts will be committed to the + * `gh-pages` branch. For convenience, this can also be a function that takes + * in a refrence to the project and returns the `Package`. + * + * Example: + * ```typescript + * export const myProject = mkNpmProject({ ... }) + * .addPackage("genStaticSite", "npm run build && mv dist/* $out") + * .add(garn.deployToGhPages(project => project.genStaticSite)); + * ``` + * + * Then running `garn run myProject.deployToGhPages` will create the `gh-pages` + * branch for you if it doesn't already exist, and add a commit containing the + * artifacts from the specified project (`genStaticSite` in the example above) + * to the branch. + * + * At this point all you need to do to deploy is push the branch to GitHub with + * `git push gh-pages:gh-pages` + */ +export function deployToGhPages( + pkg: Package | ((t: T) => Package), +): Plugin<{ deployToGhPages: Executable }, T> { + return (p) => { + if (p.defaultEnvironment == null) { + throw new Error( + `'deployToGhPages' can only be added to projects with a default environment`, + ); + } + return { + deployToGhPages: p.defaultEnvironment!.shell` + set -eu + + REPO_DIR=$(git rev-parse --show-toplevel) + TMP_DIR=$(mktemp -d) + TMP_SRC="$TMP_DIR/src" + TMP_DST="$TMP_DIR/dst" + VERSION_NAME=$(git describe --tags --dirty --always) + + function cleanup() { + rm -rf "$TMP_DIR" + } + trap cleanup EXIT + + if [ "$(git rev-parse --abbrev-ref HEAD)" = gh-pages ]; then + >&2 echo -e '${ansiRedBold}error:${ansiBold} deployToGhPages cannot run if gh-pages is currently checked out. Please change branches first.${ansiReset}' + exit 1 + fi + + git clone --quiet "$REPO_DIR" "$TMP_SRC" + git -C "$TMP_SRC" checkout gh-pages 2>/dev/null || git -C "$TMP_SRC" checkout --quiet --orphan gh-pages + cp -rv ${typeof pkg === "function" ? pkg(p) : pkg} "$TMP_DST" + chmod +w "$TMP_DST" + mv "$TMP_SRC/.git" "$TMP_DST" + git -C "$TMP_DST" add . + git -C "$TMP_DST" commit -m "Deploy $VERSION_NAME to gh-pages" + git fetch --quiet "$TMP_DST" gh-pages:gh-pages + >&2 echo -e 'Created commit to "gh-pages" branch, but it has not been pushed yet' + >&2 echo -e 'Run ${ansiBold}git push gh-pages:gh-pages${ansiReset} to deploy' + `, + }; + }; +} diff --git a/ts/mod.ts b/ts/mod.ts index 95a38f6e..55b0e1d7 100644 --- a/ts/mod.ts +++ b/ts/mod.ts @@ -24,4 +24,5 @@ export * as javascript from "./javascript/mod.ts"; // tools export { processCompose } from "./process_compose.ts"; +export { deployToGhPages } from "./deployToGhPages.ts"; export * as nix from "./nix.ts"; diff --git a/ts/testUtils.ts b/ts/testUtils.ts index 14a97f20..4feee797 100644 --- a/ts/testUtils.ts +++ b/ts/testUtils.ts @@ -9,6 +9,15 @@ type Output = { stderr: string; }; +export const runCommand = (command: Deno.Command): Output => { + const output = command.outputSync(); + return { + exitCode: output.code, + stdout: new TextDecoder().decode(output.stdout), + stderr: new TextDecoder().decode(output.stderr), + }; +}; + const printOutput = (output: Output) => { console.error(` exitcode: ${output.exitCode} @@ -46,7 +55,10 @@ export const assertStderr = (output: Output, expected: string) => { } }; -export const runExecutable = (executable: garn.Executable): Output => { +export const runExecutable = ( + executable: garn.Executable, + options: { cwd?: string } = {}, +): Output => { const tempDir = Deno.makeTempDirSync({ prefix: "garn-test" }); const nixpkgsInput = nix.nixFlakeDep("nixpkgs-repo", { url: "github:NixOS/nixpkgs/6fc7203e423bbf1c8f84cccf1c4818d097612566", @@ -70,12 +82,10 @@ export const runExecutable = (executable: garn.Executable): Output => { }), ); Deno.writeTextFileSync(`${tempDir}/flake.nix`, flakeFile); - const output = new Deno.Command("nix", { - args: ["run", tempDir], - }).outputSync(); - return { - exitCode: output.code, - stdout: new TextDecoder().decode(output.stdout), - stderr: new TextDecoder().decode(output.stderr), - }; + return runCommand( + new Deno.Command("nix", { + args: ["run", tempDir], + cwd: options.cwd, + }), + ); }; From 3826f2061d459f4888986f127d9121e33e47d0a3 Mon Sep 17 00:00:00 2001 From: Alex David Date: Thu, 16 Nov 2023 10:06:01 -0800 Subject: [PATCH 2/4] Set up git user on CI --- .github/workflows/ci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index beb67069..de7db528 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,6 +19,10 @@ jobs: fallback = true - name: Remove /opt to free up disk space run: sudo rm -rf /opt/* + - name: Set up git user + run: | + git config --global user.name "GitHub CI" + git config --global user.email "github-ci@example.com" - uses: lriesebos/nix-develop-command@v1 with: command: "just github-ci" From f6921690ec4d844acefde089145b74e371672f84 Mon Sep 17 00:00:00 2001 From: Alex David Date: Thu, 16 Nov 2023 10:39:20 -0800 Subject: [PATCH 3/4] Fix tests --- ts/deployToGhPages.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ts/deployToGhPages.test.ts b/ts/deployToGhPages.test.ts index d06b64f3..8169eb46 100644 --- a/ts/deployToGhPages.test.ts +++ b/ts/deployToGhPages.test.ts @@ -46,10 +46,10 @@ describe("deployToGhPagesBranch", () => { regexp` ^ warning: creating lock file [^\\n]+\\n - (?: - this derivation will be built:\\n - [^\\n]+\\n - building[^\\n]+\\n + ( + (this|these \\d+) derivations? will be built:\\n + ([^\\n]+\\n)+ + (building[^\\n]+\\n)+ )? Created commit to "gh-pages" branch, but it has not been pushed yet\\n Run ${ansiRegexp}git push gh-pages:gh-pages${ansiRegexp} to deploy\\n From c32d5a4427c08321f1816f954ec5aca0d26dceb6 Mon Sep 17 00:00:00 2001 From: Alex David Date: Thu, 16 Nov 2023 13:54:32 -0800 Subject: [PATCH 4/4] Address PR comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sönke Hahn --- .github/workflows/ci.yaml | 4 ++-- ts/deployToGhPages.test.ts | 34 +++++++++++++++++++++------------- ts/deployToGhPages.ts | 7 ++++--- ts/testUtils.ts | 3 ++- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index de7db528..5d91b68b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,8 +21,8 @@ jobs: run: sudo rm -rf /opt/* - name: Set up git user run: | - git config --global user.name "GitHub CI" - git config --global user.email "github-ci@example.com" + git config --global user.name "ci-test-user" + git config --global user.email "ci-test-user@example.com" - uses: lriesebos/nix-develop-command@v1 with: command: "just github-ci" diff --git a/ts/deployToGhPages.test.ts b/ts/deployToGhPages.test.ts index 8169eb46..0e20dc3c 100644 --- a/ts/deployToGhPages.test.ts +++ b/ts/deployToGhPages.test.ts @@ -15,7 +15,7 @@ const project = garn defaultEnvironment: garn.emptyEnvironment, }, { - buildProject: garn.build`echo built > $out/artifact ; echo hidden > $out/.hidden`, + buildProject: garn.build`mkdir -p $out/assets ; echo built > $out/assets/artifact ; echo hidden > $out/.hidden`, }, ) .add(garn.deployToGhPages((self) => self.buildProject)); @@ -23,8 +23,9 @@ const project = garn const mkGitRepo = () => { const path = Deno.makeTempDirSync(); const run = (...args: Array) => { - const output = runCommand(new Deno.Command("git", { args, cwd: path })); - assertSuccess(output); + const output = assertSuccess( + runCommand(new Deno.Command("git", { args, cwd: path })), + ); return output.stdout; }; run("init"); @@ -34,13 +35,14 @@ const mkGitRepo = () => { const ansiRegexp = "\\x1b\\[[0-9;]+m"; -describe("deployToGhPagesBranch", () => { +describe("deployToGhPages", () => { it("allows to create a commit on a new 'gh-pages' branch", () => { const gitRepo = mkGitRepo(); - const output = runExecutable(project.deployToGhPages, { - cwd: gitRepo.path, - }); - assertSuccess(output); + const output = assertSuccess( + runExecutable(project.deployToGhPages, { + cwd: gitRepo.path, + }), + ); assertMatch( output.stderr, regexp` @@ -61,9 +63,12 @@ describe("deployToGhPagesBranch", () => { Array.from(Deno.readDirSync(gitRepo.path)) .map((x) => x.name) .sort(), - [".git", ".hidden", "artifact"], + [".git", ".hidden", "assets"], + ); + assertEquals( + Deno.readTextFileSync(`${gitRepo.path}/assets/artifact`), + "built\n", ); - assertEquals(Deno.readTextFileSync(`${gitRepo.path}/artifact`), "built\n"); assertMatch( gitRepo.run("log", "-n1", "--pretty=format:%s"), /^Deploy [0-9a-f]{7} to gh-pages$/, @@ -87,9 +92,12 @@ describe("deployToGhPagesBranch", () => { Array.from(Deno.readDirSync(gitRepo.path)) .map((x) => x.name) .sort(), - [".git", ".hidden", "artifact"], + [".git", ".hidden", "assets"], + ); + assertEquals( + Deno.readTextFileSync(`${gitRepo.path}/assets/artifact`), + "built\n", ); - assertEquals(Deno.readTextFileSync(`${gitRepo.path}/artifact`), "built\n"); assertMatch( gitRepo.run("log", "--pretty=format:%s"), /^Deploy [0-9a-f]{7} to gh-pages\nAdd some files$/, @@ -111,7 +119,7 @@ describe("deployToGhPagesBranch", () => { Array.from(Deno.readDirSync(gitRepo.path)) .map((x) => x.name) .sort(), - [".git", ".hidden", "artifact"], + [".git", ".hidden", "assets"], ); assertMatch( gitRepo.run("log", "--pretty=format:%s"), diff --git a/ts/deployToGhPages.ts b/ts/deployToGhPages.ts index 448f52ce..b5bdb793 100644 --- a/ts/deployToGhPages.ts +++ b/ts/deployToGhPages.ts @@ -7,11 +7,12 @@ const ansiReset = "\\e[0m"; const ansiRedBold = "\\e[31;1m"; /** - * A garn plugin that allows easy deployment of a package to GitHub pages. + * A garn plugin that allows easy deployment of a package to [GitHub + * pages](https://pages.github.com/). * * @param pkg - The `Package` whose artifacts will be committed to the * `gh-pages` branch. For convenience, this can also be a function that takes - * in a refrence to the project and returns the `Package`. + * in a reference to the project and returns the `Package`. * * Example: * ```typescript @@ -60,7 +61,7 @@ export function deployToGhPages( git clone --quiet "$REPO_DIR" "$TMP_SRC" git -C "$TMP_SRC" checkout gh-pages 2>/dev/null || git -C "$TMP_SRC" checkout --quiet --orphan gh-pages cp -rv ${typeof pkg === "function" ? pkg(p) : pkg} "$TMP_DST" - chmod +w "$TMP_DST" + chmod -R +w "$TMP_DST" mv "$TMP_SRC/.git" "$TMP_DST" git -C "$TMP_DST" add . git -C "$TMP_DST" commit -m "Deploy $VERSION_NAME to gh-pages" diff --git a/ts/testUtils.ts b/ts/testUtils.ts index 4feee797..88d73037 100644 --- a/ts/testUtils.ts +++ b/ts/testUtils.ts @@ -28,9 +28,10 @@ const printOutput = (output: Output) => { `); }; -export const assertSuccess = (output: Output) => { +export const assertSuccess = (output: Output): Output => { try { assertEquals(output.exitCode, 0); + return output; } catch (e) { printOutput(output); throw e;