@faable/faable 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import { git_context } from './git_context.js';
9
9
  import { deploy_remote } from './remote/index.js';
10
10
  import { resolve_app_id } from './resolve_app_id.js';
11
11
  import { secrets } from './secrets/index.js';
12
+ import { is_superseded } from './superseded.js';
12
13
  import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs.js';
13
14
  import { upload_tag } from './upload_tag.js';
14
15
 
@@ -58,6 +59,13 @@ const deploy = {
58
59
  const config = Configuration.instance().deployConfig();
59
60
  const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
60
61
  const app = await api.getApp(app_id);
62
+ // Monorepo Root Directory: the server is the source of truth
63
+ // (App.root_dir), so no repo config file is needed. A local
64
+ // faable.json rootDir (dev override) still wins if set.
65
+ if (!config.rootDir && app.root_dir) {
66
+ config.rootDir = app.root_dir;
67
+ log.info(`📁 Monorepo root directory (from app): ${app.root_dir}`);
68
+ }
61
69
  // Capture the commit/ref/actor so the deployment records which commit
62
70
  // it came from and who pushed it (env in CI, git fallback locally).
63
71
  const git = await git_context({ workdir });
@@ -179,6 +187,10 @@ const deploy = {
179
187
  log.info(`⏳ Waiting for deployment to be promoted...`);
180
188
  let promoted = false;
181
189
  let failure = null;
190
+ let superseded = null;
191
+ // The pointer at wait-start is the PREVIOUS deployment (older than ours)
192
+ // — only a pointer CHANGE to something that isn't us needs a look.
193
+ const initialActiveId = app.status?.deployment;
182
194
  while (Date.now() - start < timeoutMs) {
183
195
  await wait(intervalMs);
184
196
  try {
@@ -187,6 +199,18 @@ const deploy = {
187
199
  promoted = true;
188
200
  break;
189
201
  }
202
+ // Superseded: the pointer moved to a deployment at least as new as
203
+ // ours (twin workflows on the same push, or a rapid follow-up
204
+ // deploy). It only moves forward — waiting longer can never succeed.
205
+ const activeId = current.status?.deployment;
206
+ if (activeId && activeId !== initialActiveId) {
207
+ const active = await api.getDeployment(activeId).catch(() => null);
208
+ if (active &&
209
+ is_superseded(deployment, active)) {
210
+ superseded = activeId;
211
+ break;
212
+ }
213
+ }
190
214
  // Watch for a terminal runtime failure so we fail fast (and red)
191
215
  // instead of timing out green. The controller marks a crash-looping
192
216
  // deployment ERROR — with a reason (e.g. a missing module) — within
@@ -197,6 +221,12 @@ const deploy = {
197
221
  failure = { phase, reason: dep.status?.reason };
198
222
  break;
199
223
  }
224
+ // CANCELED: the platform superseded this build before it produced a
225
+ // runnable (a sibling deployment of the app won the promotion).
226
+ if (phase === "CANCELED") {
227
+ superseded = dep.status?.reason ?? "canceled";
228
+ break;
229
+ }
200
230
  }
201
231
  catch (_error) {
202
232
  // Ignore transient errors while polling and keep waiting
@@ -206,6 +236,12 @@ const deploy = {
206
236
  if (promoted) {
207
237
  log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
208
238
  }
239
+ else if (superseded) {
240
+ // Not a failure: the app IS live, just on a sibling deployment (e.g.
241
+ // duplicated workflows firing on the same push). Green exit — there is
242
+ // nothing for the user to fix in THIS run.
243
+ log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
244
+ }
209
245
  else if (failure) {
210
246
  // Fail the command (non-zero exit → red GitHub Action) and surface the
211
247
  // reason the controller captured so the user sees WHY without digging.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Superseded detection for the promotion wait (deploy v3, twin-workflow
3
+ * case): the app's promoted pointer only ever moves FORWARD (the controller
4
+ * refuses to promote a deployment older than the active one), so once a
5
+ * deployment at-least-as-new as ours is promoted, waiting longer can never
6
+ * succeed. Twins from duplicated workflows land here — one wins the pointer,
7
+ * the other must exit cleanly instead of hanging to the timeout.
8
+ *
9
+ * `>=` on purpose: same-instant twins (created ms apart) supersede each
10
+ * other in whichever order the promoter resolved them.
11
+ */
12
+ const is_superseded = (mine, active) => Boolean(active &&
13
+ active.id !== mine.id &&
14
+ new Date(active.createdAt).getTime() >=
15
+ new Date(mine.createdAt).getTime());
16
+
17
+ export { is_superseded };
@@ -54,6 +54,7 @@ class Configuration {
54
54
  startCommand: this.config.startCommand,
55
55
  buildCommand: this.config.buildCommand,
56
56
  buildpack: this.config.buildpack,
57
+ rootDir: this.config.rootDir,
57
58
  next: this.config.next,
58
59
  };
59
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
- "@faabletools/buildpacks": "^1.0.0",
30
+ "@faabletools/buildpacks": "^1.7.0",
31
31
  "axios": "^1.13.2",
32
32
  "fs-extra": "^11.3.2",
33
33
  "handlebars": "^4.7.8",