Repository: electron-userland/electron-forge (ts)

Issue:
Support a dry-run publish task for post-run deployments
* [x] I have read the [contribution documentation](https://github.com/electron-userland/electron-forge/blob/master/CONTRIBUTING.md) for this project.
* [x] I agree to follow the [code of conduct](https://github.com/electron/electron/blob/master/CODE_OF_CONDUCT.md) that this project follows, as appropriate.
* [x] I have searched the issue tracker for an issue that matches the one I want to file, without success.

### Scenario

* I want to build distributables on each commit in CI
* I want to be able to publish these distributables at a later time without needing to build them again

Our current publish setup still requires you run "make" again which is wasted time and can result in different a different app being output if npm modules have updated between build and manual deployment.

### Proposal

Add a `--dry-run` flag to the publish command that does the following

* Runs `make` (as normal)
* Stores the calls it *would* make to the publishers in a JSON file `out/dry-run.{uuid}.json`
  * Artifact path and hash and publisher it would use

Add a `--from-dry-run` flag to the publish command that does the following

* Reads all `dry-run.{uuid}.json` files and concats their publish calls
* Validates all artifact hashs
* Publishes all artifacts using the paths and publishers stored in the JSON files

This would also allow you to run `publish --dry-run` on multiple platform CI machines and then copy all the outputs over to a single machine which would then run `publish --from-dry-run` and publish all artifacts at once 👍 

Pull request description:
feat(publisher): adds dryRun and resumeDryRun to the API
to allow post-make publishes

Fixes #230

<!--
Thanks for filing a pull request!
Please check off all of the steps as they are completed by replacing [ ] with [x].
-->

* [x] I have read the [contribution documentation](https://github.com/electron-userland/electron-forge/blob/master/CONTRIBUTING.md) for this project.
* [x] I agree to follow the [code of conduct](https://github.com/electron/electron/blob/master/CODE_OF_CONDUCT.md) that this project follows, as appropriate.
* [x] The changes are appropriately documented (if applicable).
* [x] The changes have sufficient test coverage (if applicable).
* [x] The testsuite passes successfully on my local machine (if applicable).

**Summarize your changes:**

Looking at the tests best explains what this does, basically you can run `publish --dry-run` and it will make distributables **but not actually upload them**.  It will instead serialize into JSON the information required to upload them including platform, arch, configs and such and save those in a standard location relative to the out directory.

Then when you resume a dry run forge will scan for all saved dry runs and restore them skipping the make step completely and relying on the saved serialized information.

This allows you to make on all three platforms and then at a later point download those artifacts to one deployment machine and deploy everything in one go.

It actually looks kinda similar to a cool thing @felixrieseberg did with slacks publish scripts https://twitter.com/felixrieseberg/status/876962403799281664


Patch:
diff --git a/src/api/make.js b/src/api/make.js
index 16a1b22ae..a2f47a142 100644
--- a/src/api/make.js
+++ b/src/api/make.js
@@ -24,11 +24,19 @@ import packager from './package';
  * @property {string} [outDir=`${dir}/out`] The path to the directory containing generated distributables
  */
 
+/**
+ * @typedef {Object} MakeResult
+ * @property {Array<string>} artifacts An array of paths to artifacts generated for this make run
+ * @property {Object} packageJSON The state of the package.json file when the make happened
+ * @property {string} platform The platform this make run was for
+ * @property {string} arch The arch this make run was for
+ */
+
 /**
  * Make distributables for an Electron application.
  *
  * @param {MakeOptions} providedOptions - Options for the make method
- * @return {Promise} Will resolve when the make process is complete
+ * @return {Promise<Array<MakeResult>>} Will resolve when the make process is complete
  */
 export default async (providedOptions = {}) => {
   // eslint-disable-next-line prefer-const, no-unused-vars
@@ -146,14 +154,21 @@ export default async (providedOptions = {}) => {
       // eslint-disable-next-line no-loop-func
       await asyncOra(`Making for target: ${target.cyan} - On platform: ${platform.cyan} - For arch: ${targetArch.cyan}`, async () => {
         try {
-          outputs.push(await maker({
+          const artifacts = await maker({
             dir: packageDir,
             appName,
             targetPlatform: platform,
             targetArch,
             forgeConfig,
             packageJSON,
-          }));
+          });
+
+          outputs.push({
+            artifacts,
+            packageJSON,
+            platform,
+            arch: targetArch,
+          });
         } catch (err) {
           if (err) {
             throw {
diff --git a/src/api/publish.js b/src/api/publish.js
index 3968a5d68..b3c81fd6e 100644
--- a/src/api/publish.js
+++ b/src/api/publish.js
@@ -1,4 +1,6 @@
 import 'colors';
+import debug from 'debug';
+import fs from 'fs-extra';
 import path from 'path';
 
 import asyncOra from '../util/ora-handler';
@@ -6,9 +8,12 @@ import getForgeConfig from '../util/forge-config';
 import readPackageJSON from '../util/read-package-json';
 import requireSearch from '../util/require-search';
 import resolveDir from '../util/resolve-dir';
+import PublishState from '../util/publish-state';
 
 import make from './make';
 
+const d = debug('electron-forge:publish');
+
 /**
  * @typedef {Object} PublishOptions
  * @property {string} [dir=process.cwd()] The path to the app to be published
@@ -17,6 +22,10 @@ import make from './make';
  * @property {string} [tag=packageJSON.version] The string to tag this release with
  * @property {Array<string>} [publishTargets=[github]] The publish targets
  * @property {MakeOptions} [makeOptions] Options object to passed through to make()
+ * @property {string} [outDir=`${dir}/out`] The path to the directory containing generated distributables
+ * @property {boolean} [dryRun=false] Whether to generate dry run meta data but not actually publish
+ * @property {boolean} [dryRunResume=false] Whether or not to attempt to resume a previously saved `dryRun` and publish
+ * @property {MakeResult} [makeResults=null] Provide results from make so that the publish step doesn't run make itself
  */
 
 /**
@@ -25,36 +34,92 @@ import make from './make';
  * @param {PublishOptions} providedOptions - Options for the Publish method
  * @return {Promise} Will resolve when the publish process is complete
  */
-export default async (providedOptions = {}) => {
+const publish = async (providedOptions = {}) => {
   // eslint-disable-next-line prefer-const, no-unused-vars
-  let { dir, interactive, authToken, tag, publishTargets, makeOptions } = Object.assign({
+  let { dir, interactive, authToken, tag, publishTargets, makeOptions, dryRun, dryRunResume, makeResults } = Object.assign({
     dir: process.cwd(),
     interactive: false,
     tag: null,
     makeOptions: {},
     publishTargets: null,
+    dryRun: false,
+    dryRunResume: false,
+    makeResults: null,
   }, providedOptions);
   asyncOra.interactive = interactive;
 
-  const makeResults = await make(Object.assign({
-    dir,
-    interactive,
-  }, makeOptions));
+  const outDir = providedOptions.outDir || path.resolve(dir, 'out');
+  const dryRunDir = path.resolve(outDir, 'publish-dry-run');
+
+  if (dryRun && dryRunResume) {
+    throw 'Can\'t dry run and resume a dry run at the same time';
+  }
+  if (dryRunResume && makeResults) {
+    throw 'Can\'t resume a dry run and use the provided makeResults at the same time';
+  }
+
+  let packageJSON = await readPackageJSON(dir);
+
+  const forgeConfig = await getForgeConfig(dir);
+
+  if (dryRunResume) {
+    d('attempting to resume from dry run');
+    const publishes = await PublishState.loadFromDirectory(dryRunDir);
+    for (const publishStates of publishes) {
+      d('publishing for given state set');
+      await publish({
+        dir,
+        interactive,
+        authToken,
+        tag,
+        publishTargets,
+        makeOptions,
+        dryRun: false,
+        dryRunResume: false,
+        makeResults: publishStates.map(({ state }) => state),
+      });
+    }
+    return;
+  } else if (!makeResults) {
+    d('triggering make');
+    makeResults = await make(Object.assign({
+      dir,
+      interactive,
+    }, makeOptions));
+  } else {
+    // Restore values from dry run
+    d('restoring publish settings from dry run');
+
+    for (const makeResult of makeResults) {
+      packageJSON = makeResult.packageJSON;
+      makeOptions.platform = makeResult.platform;
+      makeOptions.arch = makeResult.arch;
+
+      for (const makePath of makeResult.artifacts) {
+        if (!await fs.exists(makePath)) {
+          throw `Attempted to resume a dry run but an artifact (${makePath}) could not be found`;
+        }
+      }
+    }
+  }
+
+  if (dryRun) {
+    d('saving results of make in dry run state', makeResults);
+    await fs.remove(dryRunDir);
+    await PublishState.saveToDirectory(dryRunDir, makeResults);
+    return;
+  }
 
   dir = await resolveDir(dir);
   if (!dir) {
     throw 'Failed to locate publishable Electron application';
   }
 
-  const artifacts = makeResults.reduce((accum, arr) => {
-    accum.push(...arr);
+  const artifacts = makeResults.reduce((accum, makeResult) => {
+    accum.push(...makeResult.artifacts);
     return accum;
   }, []);
 
-  const packageJSON = await readPackageJSON(dir);
-
-  const forgeConfig = await getForgeConfig(dir);
-
   if (publishTargets === null) {
     publishTargets = forgeConfig.publish_targets[makeOptions.platform || process.platform];
   }
@@ -77,3 +142,5 @@ export default async (providedOptions = {}) => {
     await publisher(artifacts, packageJSON, forgeConfig, authToken, tag, makeOptions.platform || process.platform, makeOptions.arch || process.arch);
   }
 };
+
+export default publish;
diff --git a/src/electron-forge-publish.js b/src/electron-forge-publish.js
index 2dd702770..b961d1d83 100644
--- a/src/electron-forge-publish.js
+++ b/src/electron-forge-publish.js
@@ -14,6 +14,8 @@ import { getMakeOptions } from './electron-forge-make';
     .option('--auth-token', 'Authorization token for your publisher target (if required)')
     .option('--tag', 'The tag to publish to on GitHub')
     .option('--target [target[,target...]]', 'The comma-separated deployment targets, defaults to "github"')
+    .option('--dry-run', 'Triggers a publish dry run which saves state and doesn\'t upload anything')
+    .option('--from-dry-run', 'Attempts to publish artifacts from the last saved dry run')
     .allowUnknownOption(true)
     .action((cwd) => {
       if (!cwd) return;
@@ -30,6 +32,8 @@ import { getMakeOptions } from './electron-forge-make';
     interactive: true,
     authToken: program.authToken,
     tag: program.tag,
+    dryRun: program.dryRun,
+    dryRunResume: program.fromDryRun,
   };
   if (program.target) publishOpts.publishTargets = program.target.split(',');
 
diff --git a/src/publishers/github.js b/src/publishers/github.js
index 109b86340..c2eda8ae3 100644
--- a/src/publishers/github.js
+++ b/src/publishers/github.js
@@ -12,7 +12,7 @@ export default async (artifacts, packageJSON, forgeConfig, authToken, tag) => {
   const github = new GitHub(authToken, true);
 
   let release;
-  await asyncOra('Searching for target Release', async () => {
+  await asyncOra('Searching for target release', async () => {
     try {
       release = (await github.getGitHub().repos.getReleases({
         owner: forgeConfig.github_repository.owner,
diff --git a/src/util/publish-state.js b/src/util/publish-state.js
new file mode 100644
index 000000000..66820c63e
--- /dev/null
+++ b/src/util/publish-state.js
@@ -0,0 +1,70 @@
+import crypto from 'crypto';
+import fs from 'fs-extra';
+import path from 'path';
+
+const EXTENSION = '.forge.publish';
+
+export default class PublishState {
+  static async loadFromDirectory(directory) {
+    if (!await fs.exists(directory)) {
+      throw new Error(`Attempted to load publish state from a missing directory: ${directory}`);
+    }
+
+    const publishes = [];
+    for (const dirName of await fs.readdir(directory)) {
+      const subDir = path.resolve(directory, dirName);
+      const states = [];
+      if ((await fs.stat(subDir)).isDirectory()) {
+        const filePaths = (await fs.readdir(subDir))
+          .filter(fileName => fileName.endsWith(EXTENSION))
+          .map(fileName => path.resolve(subDir, fileName));
+
+        for (const filePath of filePaths) {
+          const state = new PublishState(filePath);
+          await state.load();
+          states.push(state);
+        }
+      }
+      publishes.push(states);
+    }
+    return publishes;
+  }
+
+  static async saveToDirectory(directory, artifacts) {
+    const id = crypto.createHash('SHA256').update(JSON.stringify(artifacts)).digest('hex');
+    for (const artifact of artifacts) {
+      const state = new PublishState(path.resolve(directory, id, 'null'), '', false);
+      state.setState(artifact);
+      await state.saveToDisk();
+    }
+  }
+
+  constructor(filePath, hasHash = true) {
+    this.dir = path.dirname(filePath);
+    this.path = filePath;
+    this.hasHash = hasHash;
+  }
+
+  generateHash() {
+    const content = JSON.stringify(this.state || {});
+    return crypto.createHash('SHA256').update(content).digest('hex');
+  }
+
+  setState(state) {
+    this.state = state;
+  }
+
+  async load() {
+    this.state = await fs.readJson(this.path);
+  }
+
+  async saveToDisk() {
+    if (!this.hasHash) {
+      this.path = path.resolve(this.dir, `${this.generateHash()}${EXTENSION}`);
+      this.hasHash = true;
+    }
+
+    await fs.mkdirs(path.dirname(this.path));
+    await fs.writeJson(this.path, this.state);
+  }
+}


Test patch:
diff --git a/test/fast/publish_spec.js b/test/fast/publish_spec.js
index 504b0119f..30743cf2c 100644
--- a/test/fast/publish_spec.js
+++ b/test/fast/publish_spec.js
@@ -1,5 +1,7 @@
 import chai, { expect } from 'chai';
 import chaiAsPromised from 'chai-as-promised';
+import fs from 'fs-extra';
+import os from 'os';
 import path from 'path';
 import proxyquire from 'proxyquire';
 import sinon from 'sinon';
@@ -23,6 +25,7 @@ describe('publish', () => {
       './make': async (...args) => makeStub(...args),
       '../util/resolve-dir': async dir => resolveStub(dir),
       '../util/read-package-json': () => Promise.resolve(require('../fixture/dummy_app/package.json')),
+      '../util/forge-config': () => require('../../src/util/forge-config').default(path.resolve(__dirname, '../fixture/dummy_app')),
       '../util/require-search': requireSearchStub,
     }).default;
 
@@ -41,7 +44,7 @@ describe('publish', () => {
   });
 
   it('should call the resolved publisher with the appropriate args', async () => {
-    makeStub.returns([['artifact1', 'artifact2']]);
+    makeStub.returns([{ artifacts: ['artifact1', 'artifact2'] }]);
     await publish({
       dir: __dirname,
       interactive: false,
@@ -88,4 +91,139 @@ describe('publish', () => {
     expect(requireSearchStub.getCall(2).args[1][0]).to.equal('../publishers/black_hole.js');
     expect(requireSearchStub.getCall(3).args[1][0]).to.equal('../publishers/everywhere.js');
   });
+
+  describe('dry run', () => {
+    let dir;
+
+    const fakeMake = (platform) => {
+      const ret = [
+        { artifacts: [
+          path.resolve(dir, `artifact1-${platform}`),
+          path.resolve(dir, `artifact2-${platform}`),
+        ] }, { artifacts: [
+          path.resolve(dir, `artifact3-${platform}`),
+        ] },
+        { artifacts: [
+          path.resolve(dir, `artifact4-${platform}`),
+        ] },
+      ];
+      const state = {
+        platform,
+        arch: 'x64',
+        packageJSON: { state: platform === 'darwin' ? 1 : 0 },
+      };
+      Object.assign(ret[0], state);
+      Object.assign(ret[1], state);
+      Object.assign(ret[2], state);
+      return ret;
+    };
+
+    before(async () => {
+      dir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-forge-test-'));
+    });
+
+    describe('when creating a dry run', () => {
+      beforeEach(async () => {
+        makeStub.returns(fakeMake('darwin'));
+        const dryPath = path.resolve(dir, 'out', 'publish-dry-run');
+        await fs.mkdirs(dryPath);
+        await fs.writeFile(path.resolve(dryPath, 'hash.json'), 'test');
+        await publish({
+          dir,
+          interactive: false,
+          target: [],
+          dryRun: true,
+        });
+        expect(await fs.exists(path.resolve(dryPath, 'hash.json'))).to.equal(false, 'previous hashes should be erased');
+        const backupDir = path.resolve(dir, 'out', 'backup');
+        await fs.move(dryPath, backupDir);
+        makeStub.returns(fakeMake('win32'));
+        await publish({
+          dir,
+          interactive: false,
+          target: [],
+          dryRun: true,
+        });
+        for (const backedUp of await fs.readdir(backupDir)) {
+          await fs.copy(path.resolve(backupDir, backedUp), path.resolve(dryPath, backedUp));
+        }
+      });
+
+      it('should create dry run hash JSON files', async () => {
+        expect(makeStub.callCount).to.equal(2);
+        const dryRunFolder = path.resolve(dir, 'out', 'publish-dry-run');
+        expect(await fs.exists(dryRunFolder)).to.equal(true);
+
+        const hashFolders = await fs.readdir(dryRunFolder);
+        expect(hashFolders).to.have.length(2, 'Should contain two hashes (two publishes)');
+        for (const hashFolderName of hashFolders) {
+          const hashFolder = path.resolve(dryRunFolder, hashFolderName);
+          const makes = await fs.readdir(hashFolder);
+          expect(makes).to.have.length(3, 'Should contain the results of three makes');
+          for (const makeJson of makes) {
+            const jsonPath = path.resolve(hashFolder, makeJson);
+            const contents = await fs.readFile(jsonPath, 'utf8');
+            expect(() => JSON.parse(contents), 'Should be valid JSON').to.not.throw();
+            const data = JSON.parse(contents);
+            expect(data).to.have.property('artifacts');
+            expect(data).to.have.property('platform');
+            expect(data).to.have.property('arch');
+            expect(data).to.have.property('packageJSON');
+
+            // Make the artifacts for later
+            for (const artifactPath of data.artifacts) {
+              await fs.writeFile(artifactPath, artifactPath);
+            }
+          }
+        }
+      });
+    });
+
+    describe('when resuming a dry run', () => {
+      let publisher;
+
+      beforeEach(async () => {
+        publisher = sinon.stub();
+        publisher.returns(Promise.resolve());
+        requireSearchStub.returns(publisher);
+        await publish({
+          dir,
+          interactive: false,
+          target: [__filename],
+          dryRunResume: true,
+        });
+      });
+
+      it('should successfully restore values and pass them to publisher', () => {
+        expect(makeStub.callCount).to.equal(0);
+        expect(publisher.callCount).to.equal(2, 'should call once for each platform (make run)');
+        const darwinIndex = publisher.firstCall.args[5] === 'darwin' ? 0 : 1;
+        const win32Index = darwinIndex === 0 ? 1 : 0;
+        expect(publisher.getCall(darwinIndex).args[1]).to.deep.equal({ state: 1 });
+        expect(publisher.getCall(darwinIndex).args.slice(3)).to.deep.equal([
+          undefined,
+          null,
+          'darwin',
+          'x64',
+        ]);
+        expect(publisher.getCall(darwinIndex).args[0].sort()).to.deep.equal(
+          fakeMake('darwin').reduce((accum, val) => accum.concat(val.artifacts), []).sort()
+        );
+        expect(publisher.getCall(win32Index).args[1]).to.deep.equal({ state: 0 });
+        expect(publisher.getCall(win32Index).args.slice(3)).to.deep.equal([
+          undefined,
+          null,
+          'win32',
+          'x64',
+        ]);
+        expect(publisher.getCall(win32Index).args[0].sort()).to.deep.equal(
+          fakeMake('win32').reduce((accum, val) => accum.concat(val.artifacts), []).sort()
+        );
+      });
+    });
+
+    after(async () => {
+      await fs.remove(dir);
+    });
+  });
 });
diff --git a/test/slow/api_spec_slow.js b/test/slow/api_spec_slow.js
index a1cbfc04d..f377483cf 100644
--- a/test/slow/api_spec_slow.js
+++ b/test/slow/api_spec_slow.js
@@ -247,8 +247,8 @@ describe(`electron-forge API (with installer=${installer.substr(12)})`, () => {
             if (shouldPass) {
               it(`successfully makes for config: ${JSON.stringify(optionsFetcher(), 2)}`, async () => {
                 const outputs = await forge.make(optionsFetcher());
-                for (const outputArr of outputs) {
-                  for (const output of outputArr) {
+                for (const outputResult of outputs) {
+                  for (const output of outputResult.artifacts) {
                     expect(await fs.exists(output)).to.equal(true);
                   }
                 }