Skip to content

Commit

Permalink
Merge workflow into main branch (#1)
Browse files Browse the repository at this point in the history
This is a roll-up of a week's worth of development getting this ffmpeg workflow working on all four platforms (Linux x64, Linux arm64, macOS x64, and Windows x64).
  • Loading branch information
joeyparrish authored Aug 28, 2021
2 parents b8b578c + 6857a56 commit f4dccb4
Show file tree
Hide file tree
Showing 8 changed files with 1,006 additions and 1 deletion.
1 change: 1 addition & 0 deletions .github/workflows/api-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
248 changes: 248 additions & 0 deletions .github/workflows/api-client/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// A script to communicate with the GitHub API to perform certain actions in
// the workflow.

const fs = require('fs');
const https = require('https');
const path = require('path');

// octokit is the official API client of GitHub.
const { Octokit } = require('@octokit/core');

const repo = process.env['GITHUB_REPOSITORY'];

const octokit = new Octokit({
auth: process.env['GITHUB_TOKEN'],
});

const COMMAND_MAP = {};

const MAX_REDIRECTS = 3;


// Convert a camelCase name to kebab-case.
function camelCaseToKebabCase(name) {
// Split the camelCase name into parts with a zero-length lookahead regex on
// any capital letter. Something like "methodName" should be split into
// ["method", "Name"].
const nameParts = name.split(/(?=[A-Z])/);

// Convert those parts into a kebab-case name.
return nameParts.map(part => part.toLowerCase()).join('-');
}

// Register a method that the user can invoke on the command-line. We use
// (cheap) introspection to find the argument names, so that we can
// automatically document usage of each command without worrying about the docs
// getting out of sync with the code.
function registerCommand(method) {
const methodName = method.name;
const commandName = camelCaseToKebabCase(methodName);

// Hack out the arguments from the stringified function. This is terrible
// and will not work in the general case of all JavaScript, but it does work
// here. (Don't be like me.)
const firstLine = method.toString().split('\n')[0];
const argString = firstLine.split('(')[1].split(')')[0];
const camelArgs = argString.replace(/\s+/g, '').split(',');
const args = camelArgs.map(camelCaseToKebabCase);

COMMAND_MAP[commandName] = {
commandName,
method,
args,
};
}

// A helper function to make calls to the GitHub Repo API.
async function repoApiCall(method, apiPath, data, upload=false) {
const url = `${method} /repos/${repo}${apiPath}`;

// Clone the "data" passed in.
const options = Object.assign({}, data);

// If we're uploading, that goes to a different API endpoint.
if (upload) {
options.baseUrl = 'https://uploads.github.com';
}

const response = await octokit.request(url, options);
return response.data;
}


async function draftRelease(tagName) {
// Turns "refs/tags/foo" into "foo".
tagName = tagName.replace('refs/tags/', '');

const response = await repoApiCall('POST', '/releases', {
tag_name: tagName,
name: tagName,
draft: true,
});

return response.id;
}
registerCommand(draftRelease);

async function uploadAsset(releaseId, assetPath) {
const baseName = path.basename(assetPath);
const data = await fs.promises.readFile(assetPath);

const apiPath = `/releases/${releaseId}/assets?name=${baseName}`;
await repoApiCall('POST', apiPath, {
headers: {
'content-type': 'application/octet-stream',
'content-length': data.length,
},
data,
}, /* upload= */ true);
}
// Not registered as an independent command.

async function uploadAllAssets(releaseId, folderPath) {
const folderContents = await fs.promises.readdir(folderPath);
for (const assetFilename of folderContents) {
const assetPath = path.join(folderPath, assetFilename);
await uploadAsset(releaseId, assetPath);
}
}
registerCommand(uploadAllAssets);

// A helper function that will fetch via HTTPS and follow redirects.
function fetchViaHttps(url, outputStream, redirectCount=0) {
if (redirectCount > MAX_REDIRECTS) {
throw new Error('Too many redirects!');
}

return new Promise((resolve, reject) => {
const request = https.get(url, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
// Handle HTTP redirects.
const newUrl = response.headers.location;

resolve(fetchViaHttps(newUrl, outputStream, redirectCount + 1));
} else if (response.statusCode == 200) {
response.pipe(outputStream);
outputStream.on('finish', resolve);
} else {
reject(new Error(`Bad HTTP status code: ${response.statusCode}`));
}
});
request.on('error', reject);
});
}

async function downloadAllAssets(releaseId, outputPath) {
// If the output path does not exist, create it.
try {
await fs.promises.stat(outputPath);
} catch (error) {
await fs.promises.mkdir(outputPath);
}

const apiPath = `/releases/${releaseId}/assets`;
const assetList = await repoApiCall('GET', apiPath);
for (const asset of assetList) {
const url = asset.browser_download_url;
const assetPath = path.join(outputPath, asset.name);
const outputStream = fs.createWriteStream(assetPath);

console.log(`Fetching ${url} to ${assetPath}`);
await fetchViaHttps(url, outputStream);
}
}
registerCommand(downloadAllAssets);

async function publishRelease(releaseId) {
await repoApiCall('PATCH', `/releases/${releaseId}`, { draft: false });
}
registerCommand(publishRelease);

async function updateReleaseBody(releaseId, body) {
// NOTE: If you update the release body without specifying tag_name, it gets
// reset, resulting in a new tag being created with an auto-generated name
// like "untagged-SHA1". So we need to fetch the existing name before we
// update the body, and we need to specify it here. This is not mentioned in
// GitHub's docs, and may be a bug on their end.
const release = await getRelease(releaseId);
await repoApiCall('PATCH', `/releases/${releaseId}`, {
body,
tag_name: release.tag_name,
});
}
registerCommand(updateReleaseBody);

async function getRelease(releaseId) {
return await repoApiCall('GET', `/releases/${releaseId}`);
}
registerCommand(getRelease);


// We expect a command and arguments.
const commandName = process.argv[2];
const args = process.argv.slice(3);
const command = COMMAND_MAP[commandName];
let okay = true;

if (!commandName) {
console.error('No command selected!');
okay = false;
} else if (!command) {
console.error(`Unknown command: ${commandName}`);
okay = false;
} else if (args.length != command.args.length) {
console.error(`Wrong number of arguments for command: ${commandName}`);
okay = false;
}

// If something is wrong with the way the script was called, print usage
// information. The list of commands and their arguments are gleaned from
// COMMAND_MAP, which was populated by registerCommand() and introspection of
// the command functions.
if (!okay) {
console.error('');
console.error('Usage:');
const thisScript = path.basename(process.argv[1]);

for (possibleCommand of Object.values(COMMAND_MAP)) {
console.error(
' ',
thisScript,
possibleCommand.commandName,
...possibleCommand.args.map(arg => `<${arg}>`));
}
process.exit(1);
}

// Run the command with the given arguments.
(async () => {
let response;

try {
response = await command.method(...args);
} catch (error) {
console.error('Command failed!');
console.error('');
console.error(error);
process.exit(1);
}

// If there's a return value, print it.
if (response) {
console.log(response);
}
})();
122 changes: 122 additions & 0 deletions .github/workflows/api-client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .github/workflows/api-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"@octokit/core": "^3.5.1"
}
}
Loading

0 comments on commit f4dccb4

Please sign in to comment.