From 3216f5596e3771fc225909bac1837a9c21906604 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Thu, 16 Aug 2018 13:38:13 -0700 Subject: [PATCH] Add `GitState`, `Environment` to the `VersionInfo` (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The version number displayed in the application now displays much more specific information. It now lists the Git commit from which the build was constructed, and will identify whether we have accidentally deployed a development instance (which would be slow) or an instance with uncommitted changes (which would be bad). The version information is computed during the initialization of the Webpack config. For development, this means that it is computed when you run `yarn start`, and not updated thenafter. If the stale information presents actual confusion, we would need to backport Webpack 4’s support for runtime values in `DefinePlugin` to Webpack 3 (or upgrade Webpack by a major version). Test Plan: The logic for `GitState` and `Environment` has existing tests. With both a clean tree and a dirty tree, run `yarn start` and build the static site, and check that the resulting versions are correct. wchargin-branch: use-rich-version-types --- config/env.js | 63 ++++++++++++++++++++++++++++++++++++++++ config/jest/setupJest.js | 3 ++ src/app/Page.js | 6 ++-- src/app/version.js | 24 +++++++++++++-- src/app/version.test.js | 40 ++++++++++++++++++++++--- 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/config/env.js b/config/env.js index c3dafd1..a82109c 100644 --- a/config/env.js +++ b/config/env.js @@ -1,8 +1,13 @@ // @flow +const {spawnSync, execFileSync} = require("child_process"); const fs = require("fs"); +const stringify = require("json-stable-stringify"); const path = require("path"); + const paths = require("./paths"); +/*:: import type {GitState} from "../src/app/version"; */ + // Make sure that including paths.js after env.js will read .env variables. delete require.cache[require.resolve("./paths")]; @@ -55,11 +60,69 @@ process.env.NODE_PATH = (process.env.NODE_PATH || "") .map((folder) => path.resolve(appDirectory, folder)) .join(path.delimiter); +// Get the state of the SourceCred Git repository. This requires that +// Git be installed. If this fails for you, please install Git. +// +// If the dependency on Git becomes a problem, we can consider making +// this optional. However, note that this computation is performed at +// build time, so end users of SourceCred as a library or application +// should not need this dependency. +function getGitState() /*: GitState */ { + const env = { + LANG: "C", + LC_ALL: "C", + TZ: "UTC", + GIT_CONFIG_NOSYSTEM: "1", + GIT_ATTR_NOSYSTEM: "1", + }; + + const diffIndex = spawnSync( + "git", + ["-C", __dirname, "diff-index", "--quiet", "HEAD", "--"], + {env} + ); + const dirty = diffIndex.status !== 0; + if (diffIndex.status !== 0 && diffIndex.status !== 1) { + throw new Error(diffIndex.status + ": " + diffIndex.stderr.toString()); + } + + const commitHash = execFileSync( + "git", + ["-C", __dirname, "rev-parse", "--short=12", "--verify", "HEAD"], + {env} + ) + .toString() + .trim(); + + const commitTimestamp = execFileSync( + "git", + [ + "-C", + __dirname, + "show", + "--no-patch", + "--format=%cd", + "--date=format:%Y%m%d-%H%M", + commitHash, + ], + {env} + ) + .toString() + .trim(); + + return {commitHash, commitTimestamp, dirty}; +} + +const SOURCECRED_GIT_STATE = stringify(getGitState()); +process.env.SOURCECRED_GIT_STATE = SOURCECRED_GIT_STATE; + function getClientEnvironment() { const raw = {}; // Useful for determining whether we’re running in production mode. // Most importantly, it switches React into the correct mode. raw.NODE_ENV = process.env.NODE_ENV || "development"; + // Used by `src/app/version.js`. + raw.SOURCECRED_GIT_STATE = SOURCECRED_GIT_STATE; // Stringify all values so we can feed into Webpack's DefinePlugin. const stringified = {"process.env": {}}; diff --git a/config/jest/setupJest.js b/config/jest/setupJest.js index feff21d..9fc3d4e 100644 --- a/config/jest/setupJest.js +++ b/config/jest/setupJest.js @@ -1,3 +1,6 @@ // @flow +// Set up the environment before we include any other modules. +require("../env"); + global.fetch = require("jest-fetch-mock"); diff --git a/src/app/Page.js b/src/app/Page.js index eed2f35..e92fcd4 100644 --- a/src/app/Page.js +++ b/src/app/Page.js @@ -9,7 +9,7 @@ import GithubLogo from "./GithubLogo"; import TwitterLogo from "./TwitterLogo"; import {routeData} from "./routeData"; import * as NullUtil from "../util/null"; -import {VERSION} from "./version"; +import {VERSION_SHORT, VERSION_FULL} from "./version"; export default class Page extends React.Component<{| +assets: Assets, @@ -71,7 +71,9 @@ export default class Page extends React.Component<{| diff --git a/src/app/version.js b/src/app/version.js index 0023563..4e12042 100644 --- a/src/app/version.js +++ b/src/app/version.js @@ -4,6 +4,8 @@ export type VersionInfo = {| +major: number, +minor: number, +patch: number, + +gitState: GitState, + +environment: Environment, |}; export type GitState = {| +commitHash: string, @@ -38,6 +40,7 @@ export function parseGitState(raw: ?string): GitState { } return gitState; } +const gitState = parseGitState(process.env.SOURCECRED_GIT_STATE); /** * Parse the given string as an `Environment`, throwing an error if it @@ -51,15 +54,30 @@ export function parseEnvironment(raw: ?string): Environment { } return raw; } +const environment = parseEnvironment(process.env.NODE_ENV); -export const VERSION_INFO = Object.freeze({ +export const VERSION_INFO: VersionInfo = Object.freeze({ major: 0, minor: 0, patch: 0, + gitState, + environment, }); -export function format(info: VersionInfo): string { +export function formatShort(info: VersionInfo): string { return `v${info.major}.${info.minor}.${info.patch}`; } -export const VERSION = format(VERSION_INFO); +export function formatFull(info: VersionInfo): string { + const parts = [ + `v${info.major}.${info.minor}.${info.patch}`, + info.gitState.commitHash, + info.gitState.commitTimestamp, + info.gitState.dirty ? "dirty" : "clean", + info.environment, + ]; + return parts.join("-"); +} + +export const VERSION_SHORT: string = formatShort(VERSION_INFO); +export const VERSION_FULL: string = formatFull(VERSION_INFO); diff --git a/src/app/version.test.js b/src/app/version.test.js index 41c5e44..b7d6865 100644 --- a/src/app/version.test.js +++ b/src/app/version.test.js @@ -1,11 +1,16 @@ // @flow -import {type Environment, parseEnvironment, parseGitState} from "./version"; +import { + type Environment, + type VersionInfo, + formatFull, + formatShort, + parseEnvironment, + parseGitState, +} from "./version"; describe("app/version", () => { - // Like `VersionInfo`, but with some extra properties that will - // shortly be added to that type. - const version = () => ({ + const version = (): VersionInfo => ({ major: 3, minor: 13, patch: 37, @@ -106,4 +111,31 @@ describe("app/version", () => { expect(() => parseEnvironment("wat")).toThrow('environment: "wat"'); }); }); + + describe("formatShort", () => { + it("includes the major, minor, and patch versions", () => { + expect(formatShort(version())).toContain("3.13.37"); + }); + it("does not include the Git hash", () => { + expect(formatShort(version())).not.toContain("d0e1"); + }); + it("does not include the Node environment", () => { + expect(formatShort(version())).not.toContain("-test"); + }); + }); + + describe("formatFull", () => { + it("includes the major, minor, and patch versions", () => { + expect(formatFull(version())).toContain("3.13.37"); + }); + it("includes the Git hash and timestamp", () => { + expect(formatFull(version())).toContain("d0e1a2d3b4e5-20010203-0405"); + }); + it("includes the dirty state", () => { + expect(formatFull(version())).toContain("-dirty"); + }); + it("includes the Node environment", () => { + expect(formatFull(version())).toContain("-test"); + }); + }); });