Skip to content

Commit

Permalink
Fix abstractions, test packing side
Browse files Browse the repository at this point in the history
This ensures the most complicated part of the deploy actions are tested.

I rolled my own testing mechanism so I could 1. not have an extra
dependency variable and 2. ensure everything is run sequentially. It's
also extremely simple by design, and directly prints out what GitHub
Actions understands.

I also added some helpers to help track down errors better. Aside from
that, my main goal is to just reduce how many moving parts there are.
  • Loading branch information
dead-claudia committed Sep 19, 2024
1 parent ce0701b commit f2ff12f
Show file tree
Hide file tree
Showing 24 changed files with 535 additions and 218 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ on:
- main

jobs:
test_config:
test:
runs-on: ubuntu-latest
steps:
- run: git clone --depth=1 "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
- run: npm ci
- run: npx eslint .
- run: node ./lib/config.js
- run: npm test
24 changes: 23 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,40 @@ export const projects = {
// tokenExpiryDate: d(1970, 1, 1),
// tokenName: "NPM_TOKEN",
// },

// These are only for testing purposes.
"test-package": {
location: "MithrilJS/infra",
tokenExpiryDate: d(1970, 1, 1),
tokenName: "INFRA_TEST_TOKEN",
},
},

// Keys should be locations. A `CNAME` will be checked for in the deployment if the key isn't
// of the form `mithriljs.github.io/REPO`.
"github-pages": {
// "mithriljs": {
// "mithril.js.org": {
// location: "MithrilJS/mithril.js",
// tokenExpiryDate: d(1970, 1, 1),
// tokenName: "GH_PAGES_TOKEN",
// },

// These are only for testing purposes.
"example.com": {
location: "MithrilJS/infra",
tokenExpiryDate: d(1970, 1, 1),
tokenName: "INFRA_TEST_TOKEN",
},
"mithriljs.github.io/infra": {
location: "MithrilJS/infra",
tokenExpiryDate: d(1970, 1, 1),
tokenName: "INFRA_TEST_TOKEN",
},
},
}

export const localTokenExpiryDates = {
INFRA_TEST_TOKEN: 8640000000000000, // max date
NPM_TOKEN: d(2025, 9, 13),
GH_PAGES_TOKEN: d(2025, 9, 13),
}
Expand Down
20 changes: 8 additions & 12 deletions lib/deploy/github-pages.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {FAIL, fail, getDeployToken, reportRunError, run} from "../util.js"
import {Fail, fail, getRequiredEnv, reportRunError, run} from "../util.js"
import {getIDToken} from "../oidc-get-token.js"
import {getRequest} from "../api-client.js"
import {projects} from "../config.js"

const DEPLOYMENT_TIMEOUT = 60_000
const SIZE_LIMIT_BYTES = 2 ** 30
Expand Down Expand Up @@ -49,18 +48,15 @@ const deploymentErrorMessageMap = new Map([
])

/**
* @param {string} idToken
* @param {import("../util.js").DeployPayload} payload
* @param {AbortSignal} signal
* @param {string} deployToken
*/
export async function deployToGitHubPages(payload) {
const deployToken = getDeployToken(payload, projects["github-pages"])

export async function deployToGitHubPages(payload, deployToken) {
const idToken = await getIDToken()

const request = getRequest(deployToken)

const buildActor = process.env.GITHUB_ACTOR
const buildActor = getRequiredEnv("GITHUB_ACTOR")
let deploymentPending = false
let startTime

Expand Down Expand Up @@ -98,7 +94,7 @@ export async function deployToGitHubPages(payload) {
reportRunError(JSON.stringify(data))
}

throw FAIL
throw new Fail()
}
}

Expand Down Expand Up @@ -179,7 +175,7 @@ export async function deployToGitHubPages(payload) {
reportRunError(`Ensure GitHub Pages has been enabled: https://github.com/${payload.repo}/settings/pages`)
}

throw FAIL
throw new Fail()
}

// Don't attempt to check status if no deployment was created
Expand Down Expand Up @@ -245,15 +241,15 @@ export async function deployToGitHubPages(payload) {
reportRunError(`Failed with status code: ${errorStatus}`)
// Explicitly cancel the deployment
onCancel()
throw FAIL
throw new Fail()
}

// Handle timeout
if (Date.now() - startTime >= DEPLOYMENT_TIMEOUT) {
reportRunError("Timeout reached, aborting!")
// Explicitly cancel the deployment
onCancel()
throw FAIL
throw new Fail()
}
}
} finally {
Expand Down
23 changes: 10 additions & 13 deletions lib/deploy/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,14 @@ import {spawn} from "node:child_process"

import artifact from "@actions/artifact"

import {captureToString, fail, getDeployToken, parsePackageInfo} from "../util.js"
import {projects} from "../config.js"
import {captureToString, fail, getRequiredEnv, parsePackageInfo} from "../util.js"

/**
* @param {import("../util.js").DeployPayload} payload
* @param {AbortSignal} signal
* @param {string} deployToken
*/
export async function deployToNpm(payload) {
const deployToken = getDeployToken(payload, projects.npm)

const artifactFile = path.join(process.env.GITHUB_WORKSPACE, payload.tarballName)
export async function deployToNpm(payload, deployToken) {
const artifactFile = path.join(getRequiredEnv("GITHUB_WORKSPACE"), payload.tarballName)

console.log("::group::Validating file")

Expand All @@ -26,7 +23,7 @@ export async function deployToNpm(payload) {

await artifact.downloadArtifact(payload.artifactId, {
findBy: {
token: process.env.GITHUB_TOKEN,
token: getRequiredEnv("GITHUB_TOKEN"),
repositoryOwner,
repositoryName,
workflowRunId: payload.workflowRunId,
Expand Down Expand Up @@ -67,8 +64,8 @@ export async function deployToNpm(payload) {

const packageInfo = await parsePackageInfo(artifactFile, stdout)

if (packageInfo.name !== payload.packageName) {
fail(`Tarball package name ${packageInfo.name} does not match provided package name ${payload.packageName}.`)
if (packageInfo.name !== payload.target) {
fail(`Tarball package name ${packageInfo.name} does not match provided package name ${payload.target}.`)
}
} finally {
console.log("::endgroup::")
Expand All @@ -81,12 +78,12 @@ export async function deployToNpm(payload) {
console.log("Registering authorization token secret")

await fs.appendFile(
path.join(process.env.HOME, ".npmrc"),
path.resolve(getRequiredEnv("HOME"), ".npmrc"),
`\n//registry.npmjs.org/:_authToken=${deployToken}\n`,
{encoding: "utf-8"},
)

console.log(`Publishing package ${payload.packageName}`)
console.log(`Publishing package ${payload.target}`)

const publishProcess = spawn("npm", ["publish", artifactFile])
await once(publishProcess, "exit")
Expand All @@ -102,5 +99,5 @@ export async function deployToNpm(payload) {
console.log("::endgroup::")
}

console.log(`Package ${payload.packageName} published successfully`)
console.log(`Package ${payload.target} published successfully`)
}
44 changes: 22 additions & 22 deletions lib/entry/deploy.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import * as path from "node:path"

import {fail, run, validateDeployPayload} from "../util.js"
import artifact from "@actions/artifact"

import {fail, getRequiredEnv, run, validateDeployPayload} from "../util.js"
import {getRequest} from "../api-client.js"

import {packGitHubPages} from "../pack/github-pages.js"
import {packNpm} from "../pack/npm.js"

run(async() => {
if (process.platform !== "linux") {
fail("This action can only be run on Linux hosts.")
return fail("This action can only be run on Linux hosts.")
}

const type = process.env.INPUT_TYPE
const repo = getRequiredEnv("GITHUB_REPOSITORY")
const type = getRequiredEnv("INPUT_TYPE")
const token = getRequiredEnv("INPUT_TOKEN")
const actionRepo = getRequiredEnv("GITHUB_ACTION_REPOSITORY")
const workflowRunId = getRequiredEnv("GITHUB_RUN_ID")
const buildVersion = getRequiredEnv("GITHUB_SHA")


const rootDir = path.resolve(
process.env.INPUT_ROOT_DIR ||
Expand All @@ -29,11 +37,11 @@ run(async() => {
return fail("Input `type` is required")

case "npm":
artifactFile = await packNpm(rootDir)
artifactFile = await packNpm(rootDir, repo)
break

case "github-pages":
artifactFile = await packGitHubPages(rootDir)
artifactFile = await packGitHubPages(rootDir, repo)
break

default:
Expand All @@ -43,46 +51,38 @@ run(async() => {
console.log("::endgroup::")
}

console.log(`::group::Uploading tarball ${artifactFile} as an artifact`)
console.log(`::group::Uploading tarball ${artifactFile} as an artifact ${type}`)

try {
if (!process.env.INPUT_TOKEN) {
throw new TypeError("Deploy token must be present and non-empty")
}

const {base: tarballName, dir: artifactDir} = path.parse(artifactFile)

console.log(`Uploading ${artifactFile} as artifact ${type}`)

const {default: artifact} = await import("@actions/artifact")
const uploadResponse = await artifact.uploadArtifact(type, [tarballName], artifactDir)

if (uploadResponse.id === undefined) {
fail("Artifact upload failed to yield an ID")
return fail("Artifact upload failed to yield an ID")
}

const [thisOwner, thisRepo] = process.env.GITHUB_ACTION_REPOSITORY.split("/")

console.log(`Issuing dispatch event to ${thisOwner}/${thisRepo}`)
console.log(`Issuing dispatch event to ${actionRepo}`)

try {
const request = getRequest(process.env.INPUT_TOKEN)
const request = getRequest(token)
const [thisOwner, thisRepo] = actionRepo.split("/")

await request("POST /repos/{owner}/{repo}/dispatches", {
owner: thisOwner,
repo: thisRepo,
event_type: "deploy",
client_payload: validateDeployPayload({
repo: process.env.GITHUB_REPOSITORY,
repo,
type,
tarballName,
artifactId: uploadResponse.id,
workflowRunId: process.env.GITHUB_RUN_ID,
buildVersion: process.env.GITHUB_SHA,
workflowRunId,
buildVersion,
}),
})
} catch (e) {
fail(`::error title=Failed to create dispatch event::${e.message}`)
return fail(`::error title=Failed to create dispatch event::${e.message}`)
}
} finally {
console.log("::endgroup::")
Expand Down
74 changes: 68 additions & 6 deletions lib/entry/handle-deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,80 @@
// If there's an artifact named `artifact.tar`, it can upload that to actions on its own,
// without the user having to do the tar process themselves.

import {getDeployPayload, run} from "../util.js"
import * as fs from "node:fs/promises"
import * as path from "node:path"

import {extractJsonFields, fail, run, validateDeployPayload} from "../util.js"
import {localTokenExpiryDates, projects} from "../config.js"

async function getPayload() {
switch (process.env.GITHUB_EVENT_NAME) {
case "workflow_dispatch": {
const eventPath = path.resolve(process.env.GITHUB_EVENT_PATH)
const event = JSON.parse(await fs.readFile(eventPath, "utf-8"))
const raw = extractJsonFields(eventPath, "event", event, {inputs: "object"}).inputs
return validateDeployPayload(raw)
}

case "repository_dispatch": {
const eventPath = path.resolve(process.env.GITHUB_EVENT_PATH)
const event = JSON.parse(await fs.readFile(eventPath, "utf-8"))
const raw = extractJsonFields(eventPath, "event", event, {client_payload: "object"}).client_payload
return validateDeployPayload(raw)
}

default:
return fail(`Unknown value for \`GITHUB_EVENT_NAME\`: ${process.env.GITHUB_EVENT_NAME}`)
}
}

/** @param {import("../util.js").DeployPayload} payload */
function getDeployToken(payload) {
const now = Date.now()

if (!Object.hasOwn(projects, payload.type)) {
return fail(`Unrecognized project type: ${payload.type}`)
}

if (!Object.hasOwn(projects[payload.type], payload.target)) {
return fail(`Refusing to publish ${payload.target} as it is not allowlisted for`)
}

const project = projects[payload.type][payload.target]

if (project.location !== payload.repo) {
return fail(`Refusing to publish ${payload.target} as its repo is not allowlisted for`)
}

if (project.tokenExpiryDate <= now) {
return fail(`Refusing to publish ${payload.target} as its public token appears to have expired`)
}

if (localTokenExpiryDates[project.tokenName] <= now) {
return fail(`Refusing to publish ${payload.target} as the local deploy token for it (${project.tokenName}) appears to have expired`)
}

let deployToken = process.env[`INPUT_${project.tokenName}`]

if (!deployToken || !(deployToken = deployToken.trim())) {
return fail(`Refusing to publish ${payload.target} as the local deploy token (${project.tokenName}) is empty or missing`)
}

return deployToken
}

run(async() => {
const payload = await getDeployPayload()
const payload = await getPayload()
const deployToken = getDeployToken(payload)
let deployFn

if (payload.type === "npm") {
const {deployToNpm} = await import("../deploy/npm.js")
await deployToNpm(payload)
deployFn = (await import("../deploy/npm.js")).deployToNpm
} else if (payload.type === "github-pages") {
const {deployToGitHubPages} = await import("../deploy/github-pages.js")
await deployToGitHubPages(payload)
deployFn = (await import("../deploy/github-pages.js")).deployToGitHubPages
} else {
throw new Error(`Unimplemented payload type: ${payload.type}`)
}

return deployFn(payload, deployToken)
})
Loading

0 comments on commit f2ff12f

Please sign in to comment.