Skip to content

Commit 5be9af3

Browse files
committed
feat: add "github-workflow" mode
1 parent 6eb0df4 commit 5be9af3

File tree

8 files changed

+341
-21
lines changed

8 files changed

+341
-21
lines changed

.idea/Silverback.iml

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/silverback-gatsby/publisher.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineConfig } from '@amazeelabs/publisher';
22

33
export default defineConfig({
4+
mode: 'local',
45
commands: {
56
build: {
67
command: 'pnpm build:gatsby',

packages/npm/@amazeelabs/publisher/src/cli.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
1-
import { existsSync } from 'fs';
21
import { HttpTerminator } from 'http-terminator/src/types';
3-
import { join } from 'path';
4-
import { loadSync } from 'ts-import';
52

63
import { runServer } from './server';
7-
import { setConfig } from './tools/config';
84
import { core } from './tools/core';
95
import { initDatabase } from './tools/database';
106

11-
const configPath = join(process.cwd(), 'publisher.config.ts');
12-
if (!existsSync(configPath)) {
13-
console.error(`Publisher config not found: ${configPath}`);
14-
process.exit(1);
15-
}
16-
const config = loadSync(configPath, {
17-
compiledJsExtension: '.cjs',
18-
}).default;
19-
setConfig(config);
20-
217
core.output$.subscribe((chunk) => {
228
process.stdout.write(chunk);
239
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { execSync, SpawnSyncReturns } from 'node:child_process';
2+
3+
import {
4+
ApplicationState,
5+
WorkflowPublisherPayload,
6+
} from '@amazeelabs/publisher-shared';
7+
import { pairwise } from 'rxjs';
8+
9+
import { getConfigGithubWorkflow as config } from '../tools/config';
10+
import { saveBuildInfo } from '../tools/database';
11+
import { TaskController, TaskJob } from '../tools/queue';
12+
import { core } from './core';
13+
14+
export const buildTask: (args?: { clean: boolean }) => TaskJob =
15+
(args) => async (controller) => {
16+
core.state.buildNumber++;
17+
core.state.applicationState$.next(
18+
core.state.buildNumber === 1
19+
? ApplicationState.Starting
20+
: ApplicationState.Updating,
21+
);
22+
23+
const startedAt = Date.now();
24+
25+
const output: Array<string> = [];
26+
const outputSubscription = core.output$.subscribe((chunk) => {
27+
output.push(
28+
`${new Date().toISOString().substring(0, 19).replace('T', ' ')} ${chunk}`,
29+
);
30+
});
31+
32+
const finalizeBuild = (isSuccess: boolean): boolean => {
33+
core.state.applicationState$.next(
34+
isSuccess ? ApplicationState.Ready : ApplicationState.Error,
35+
);
36+
saveBuildInfo({
37+
type: 'github-workflow',
38+
startedAt,
39+
finishedAt: Date.now(),
40+
success: isSuccess,
41+
logs: output.join(''),
42+
});
43+
outputSubscription.unsubscribe();
44+
return isSuccess;
45+
};
46+
47+
const attempts =
48+
core.state.buildNumber === 1
49+
? 3 // The first build gets 3 attempts.
50+
: 1;
51+
for (let attempt = 1; attempt <= attempts; attempt++) {
52+
const result =
53+
attempt === 2
54+
? await runWorkflow({ controller, clean: true })
55+
: await runWorkflow({ controller, clean: !!args?.clean });
56+
if (result) {
57+
return finalizeBuild(true);
58+
}
59+
}
60+
return finalizeBuild(false);
61+
};
62+
63+
async function runWorkflow(args: {
64+
clean: boolean;
65+
controller: TaskController;
66+
}): Promise<boolean> {
67+
return new Promise<boolean>((resolve) => {
68+
core.output$.next('Starting the workflow', 'info');
69+
70+
const timeout = setTimeout(() => {
71+
core.output$.next('Timeout reached', 'error');
72+
args.controller.cancel();
73+
}, config().workflowTimeout);
74+
75+
args.controller.onCancel(async () => {
76+
core.output$.next('Cancelling the workflow', 'warning');
77+
await cancelWorkflow();
78+
clearTimeout(timeout);
79+
return resolve(false);
80+
});
81+
82+
try {
83+
execSync(
84+
`gh workflow run ${config().workflow} --repo ${config().repo} --ref ${config().ref} --json`,
85+
{
86+
input: JSON.stringify({
87+
...config().inputs,
88+
publisher_payload: JSON.stringify({
89+
callbackUrl:
90+
config().publisherBaseUrl + '/github-workflow-status',
91+
clearCache: args.clean,
92+
environmentVariables: config().environmentVariables,
93+
} satisfies WorkflowPublisherPayload),
94+
}),
95+
},
96+
);
97+
} catch (error) {
98+
core.output$.next('Error starting the workflow', 'error');
99+
logExecError(error);
100+
101+
clearTimeout(timeout);
102+
return resolve(false);
103+
}
104+
105+
const subscription = core.state.workflowState$
106+
.pipe(pairwise())
107+
.subscribe(([previous, current]) => {
108+
if (current === 'started') {
109+
core.output$.next('Workflow started', 'info');
110+
core.output$.next('Logs: ' + core.state.workflowRunUrl);
111+
return;
112+
}
113+
if (
114+
previous === 'started' &&
115+
(current === 'success' || current === 'failure')
116+
) {
117+
subscription.unsubscribe();
118+
current === 'success'
119+
? core.output$.next('Workflow succeeded', 'success')
120+
: core.output$.next('Workflow failed or cancelled', 'error');
121+
core.output$.next('Logs: ' + core.state.workflowRunUrl);
122+
123+
clearTimeout(timeout);
124+
return resolve(current === 'success');
125+
}
126+
});
127+
});
128+
}
129+
130+
async function cancelWorkflow(): Promise<void> {
131+
type Run = { name: string; conclusion: string; databaseId: number };
132+
133+
function matchesEnvironment(run: Run): boolean {
134+
return run.name.includes(`[env: ${config().environment}]`);
135+
}
136+
function isCompleted(run: Run): boolean {
137+
return !!run.conclusion;
138+
}
139+
140+
const listCommand = `gh run list --workflow=${config().workflow} --repo ${config().repo} --json name,conclusion,databaseId --limit 100`;
141+
142+
try {
143+
// Cancel the running workflows.
144+
const result = execSync(listCommand).toString();
145+
const runs = JSON.parse(result) as Array<Run>;
146+
for (const run of runs) {
147+
if (!isCompleted(run) && matchesEnvironment(run)) {
148+
execSync(`gh run cancel ${run.databaseId} --repo ${config().repo}`);
149+
}
150+
}
151+
152+
// Wait for the workflows to stop. Give it a minute.
153+
// This may slightly impact the GitHub API rate limits, but cancellations
154+
// are quite rare operations.
155+
const checkAttempts = 6;
156+
const delay = 10_000;
157+
for (let checkAttempt = 1; checkAttempt <= checkAttempts; checkAttempt++) {
158+
await new Promise((resolve) => setTimeout(resolve, delay));
159+
const result = execSync(listCommand).toString();
160+
const runs = JSON.parse(result) as Array<Run>;
161+
if (runs.every((run) => isCompleted(run) || !matchesEnvironment(run))) {
162+
return;
163+
}
164+
}
165+
} catch (error) {
166+
core.output$.next('Error canceling the workflow', 'error');
167+
logExecError(error);
168+
}
169+
}
170+
171+
function isSpawnError(error: unknown): error is SpawnSyncReturns<Buffer> {
172+
return !!error && typeof error === 'object' && 'status' in error;
173+
}
174+
175+
function logExecError(error: unknown): void {
176+
if (isSpawnError(error)) {
177+
core.output$.next(`Error: ${error}`);
178+
core.output$.next(`Exit code: ${error.status}`);
179+
core.output$.next(`Stdout: ${error.stdout?.toString()}`);
180+
core.output$.next(`Stderr: ${error.stderr?.toString()}`);
181+
}
182+
console.error(error);
183+
}
Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,48 @@
1+
import { ApplicationState } from '@amazeelabs/publisher-shared';
2+
import { BehaviorSubject, Subject } from 'rxjs';
3+
14
import { Core } from '../tools/core';
5+
import { OutputSubject } from '../tools/output';
6+
import { Queue } from '../tools/queue';
7+
import { buildTask } from './build';
8+
9+
type WorkflowState = 'unknown' | 'started' | 'success' | 'failure';
10+
11+
class CoreGithubWorkflow implements Core {
12+
state = {
13+
applicationState$: new Subject<ApplicationState>(),
14+
buildNumber: 0,
15+
workflowState$: new BehaviorSubject<WorkflowState>('unknown'),
16+
workflowRunUrl: '',
17+
};
18+
19+
output$ = new OutputSubject();
20+
21+
queue = new Queue();
22+
23+
start = () => {
24+
this.queue.add({ job: buildTask() });
25+
this.queue.run();
26+
};
27+
28+
stop = async () => {
29+
await this.queue.clear();
30+
};
31+
32+
build = (): void => {
33+
// Consider any pending task a build task.
34+
if (!this.queue.hasPendingTasks()) {
35+
this.queue.add({ job: buildTask() });
36+
}
37+
};
38+
39+
clean = async (): Promise<void> => {
40+
await this.queue.clear();
41+
this.queue.add({ job: buildTask({ clean: true }) });
42+
};
43+
}
44+
45+
const core = new CoreGithubWorkflow();
46+
core.state.applicationState$.next(ApplicationState.Starting);
247

3-
export const core = {} as Core;
48+
export { core };

packages/npm/@amazeelabs/publisher/src/server.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { ApplicationState } from '@amazeelabs/publisher-shared';
1+
import {
2+
ApplicationState,
3+
workflowStatusNotificationSchema,
4+
} from '@amazeelabs/publisher-shared';
25
import cors from 'cors';
36
import express from 'express';
47
import expressWs from 'express-ws';
@@ -16,7 +19,7 @@ import {
1619
isSessionRequired,
1720
} from './tools/authentication';
1821
import { getConfig } from './tools/config';
19-
import { core } from './tools/core';
22+
import { core, CoreGithubWorkflow } from './tools/core';
2023
import { getDatabase } from './tools/database';
2124
import {
2225
getOAuth2AuthorizeUrl,
@@ -61,6 +64,8 @@ const runServer = async (): Promise<HttpTerminator> => {
6164
// @TODO see if we need to lock this down
6265
app.use(referrerPolicy());
6366

67+
app.use(express.json());
68+
6469
app.use((req, res, next) => {
6570
res.set('Cache-control', 'no-cache');
6671
next();
@@ -253,6 +258,19 @@ const runServer = async (): Promise<HttpTerminator> => {
253258
res.redirect('/oauth/login');
254259
});
255260

261+
app.post('/github-workflow-status', async (req, res) => {
262+
const result = workflowStatusNotificationSchema.safeParse(req.body);
263+
if (!result.success) {
264+
console.error(result.error);
265+
res.status(400).send('Invalid request\n');
266+
return;
267+
}
268+
const { status, workflowRunUrl } = result.data;
269+
(core as CoreGithubWorkflow).state.workflowRunUrl = workflowRunUrl;
270+
(core as CoreGithubWorkflow).state.workflowState$.next(status);
271+
res.send();
272+
});
273+
256274
app.get('*', (req, res, next) => {
257275
if (!req.app.locals.isReady) {
258276
if (req.accepts('text/html')) {

0 commit comments

Comments
 (0)