Skip to content

Commit 175ff18

Browse files
committed
feat(deploy): propose release version for deployments
New propose_release: --release flag > FAABLE_RELEASE env > latest git tag (strip v, semver-shaped only), else omit. Sent as the deployment's release so the platform injects it as FAABLE_RELEASE. Mirrors Sentry's propose-version; no SHA fallback (the commit already travels as github_commit).
1 parent e23955f commit 175ff18

5 files changed

Lines changed: 145 additions & 2 deletions

File tree

src/api/FaableApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export class FaableApi<T = any> {
207207
manifest: { path: string; sha: string; size: number; mode?: number }[];
208208
plan?: unknown;
209209
};
210+
release?: string;
210211
github_commit?: string;
211212
github_ref?: string;
212213
github_actor?: string;

src/commands/deploy/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Configuration } from '../../lib/Configuration'
44
import { log } from '../../log'
55
import { link } from '../link'
66
import { git_context } from './git_context'
7+
import { propose_release } from './release_version'
78
import { deploy_remote } from './remote'
89
import { resolve_app_id } from './resolve_app_id'
910
import { secrets } from './secrets'
@@ -12,6 +13,7 @@ import { is_superseded } from './superseded'
1213
export interface DeployCommandArgs {
1314
app_id: string
1415
workdir?: string
16+
release?: string
1517
}
1618

1719
export const deploy: CommandModule<unknown, DeployCommandArgs> = {
@@ -32,6 +34,11 @@ export const deploy: CommandModule<unknown, DeployCommandArgs> = {
3234
type: 'string',
3335
description: 'Working directory'
3436
})
37+
.option('release', {
38+
type: 'string',
39+
description:
40+
'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
41+
})
3542
.showHelpOnFail(false) as any
3643
},
3744

@@ -60,14 +67,28 @@ export const deploy: CommandModule<unknown, DeployCommandArgs> = {
6067
// it came from and who pushed it (env in CI, git fallback locally).
6168
const git = await git_context({ workdir })
6269

70+
// Propose the release version (--release > FAABLE_RELEASE > git tag).
71+
// Optional: when absent the platform injects no FAABLE_RELEASE and the
72+
// app falls back to its own version source.
73+
const proposed = await propose_release({ workdir, explicit: args.release })
74+
if (proposed) {
75+
log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`)
76+
}
77+
6378
// Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
6479
// CLI no longer builds — it uploads the source and the platform builds
6580
// server-side (framework detection, buildpacks, artifact/image output all
6681
// live in the builder). No Docker, no local fallback: a rejected admission
6782
// (build_mode=local opt-out, or the global kill-switch off) or a build
6883
// error throws and exits red.
6984
log.info(`🚀 Deploying "${app.name}" (${app.id})`)
70-
const deployment = await deploy_remote({ api, app, git, workdir })
85+
const deployment = await deploy_remote({
86+
api,
87+
app,
88+
git,
89+
release: proposed?.release,
90+
workdir
91+
})
7192

7293
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`
7394
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import test from "ava";
2+
import { propose_release } from "./release_version";
3+
4+
test("explicit --release wins and is never validated", async (t) => {
5+
const got = await propose_release({
6+
explicit: "banana-phone",
7+
env: { FAABLE_RELEASE: "9.9.9" },
8+
run: async () => {
9+
throw new Error("git should not be consulted with an explicit release");
10+
},
11+
});
12+
t.deepEqual(got, { release: "banana-phone", source: "--release" });
13+
});
14+
15+
test("FAABLE_RELEASE env wins over git", async (t) => {
16+
const got = await propose_release({
17+
env: { FAABLE_RELEASE: "2.0.0-rc.1" },
18+
run: async () => {
19+
throw new Error("git should not be consulted when the env var is set");
20+
},
21+
});
22+
t.deepEqual(got, { release: "2.0.0-rc.1", source: "FAABLE_RELEASE env" });
23+
});
24+
25+
test("falls back to the latest v-prefixed git tag, stripped", async (t) => {
26+
const got = await propose_release({
27+
env: {},
28+
run: async (command) =>
29+
command.includes('--match "v[0-9]*"') ? "v1.31.0" : undefined,
30+
});
31+
t.deepEqual(got, { release: "1.31.0", source: "git tag v1.31.0" });
32+
});
33+
34+
test("retries without --match for unprefixed release tags", async (t) => {
35+
const got = await propose_release({
36+
env: {},
37+
run: async (command) =>
38+
command.includes("--match") ? undefined : "3.2.1",
39+
});
40+
t.deepEqual(got, { release: "3.2.1", source: "git tag 3.2.1" });
41+
});
42+
43+
test("non-version tags are rejected → undefined", async (t) => {
44+
const got = await propose_release({
45+
env: {},
46+
run: async (command) =>
47+
command.includes("--match") ? undefined : "nightly",
48+
});
49+
t.is(got, undefined);
50+
});
51+
52+
test("no git repo / no tags → undefined (deploy proceeds without release)", async (t) => {
53+
const got = await propose_release({ env: {}, run: async () => undefined });
54+
t.is(got, undefined);
55+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { exec } from "child_process";
2+
3+
type Runner = (command: string) => Promise<string | undefined>;
4+
5+
// Quiet git runner, same contract as git_context's: trimmed stdout or
6+
// undefined on any failure. A deploy must never fail because a release
7+
// version couldn't be proposed.
8+
const gitRunner =
9+
(workdir?: string): Runner =>
10+
command =>
11+
new Promise(resolve => {
12+
exec(command, { cwd: workdir }, (err, stdout) => {
13+
if (err) return resolve(undefined);
14+
const out = stdout?.toString().trim();
15+
resolve(out || undefined);
16+
});
17+
});
18+
19+
// Loose version shape: "starts like semver". Filters out non-version tags
20+
// (e.g. "nightly", "deploy-2026-07-01") when falling back to git describe;
21+
// explicit values (--release / FAABLE_RELEASE) are NEVER validated — the
22+
// platform treats release as free text.
23+
const looksLikeVersion = (v: string) => /^\d+\.\d+\.\d+/.test(v);
24+
25+
const stripV = (tag: string) => tag.replace(/^v/, "");
26+
27+
/**
28+
* Propose the release version for a deploy (Sentry propose-version pattern):
29+
* explicit `--release` > `FAABLE_RELEASE` env > latest reachable git tag
30+
* (`v1.2.3` or `1.2.3`, e.g. the tag semantic-release created) > undefined.
31+
* When undefined the field is omitted from the deployment and the platform
32+
* injects no FAABLE_RELEASE — the app falls back to its own version source.
33+
* No SHA fallback: the commit already travels as `github_commit`.
34+
*/
35+
export const propose_release = async (opts?: {
36+
workdir?: string;
37+
explicit?: string;
38+
env?: Record<string, string | undefined>;
39+
run?: Runner;
40+
}): Promise<{ release: string; source: string } | undefined> => {
41+
const env = opts?.env ?? process.env;
42+
const run = opts?.run ?? gitRunner(opts?.workdir);
43+
44+
if (opts?.explicit) return { release: opts.explicit, source: "--release" };
45+
if (env.FAABLE_RELEASE)
46+
return { release: env.FAABLE_RELEASE, source: "FAABLE_RELEASE env" };
47+
48+
// Latest tag reachable from HEAD. Try version-shaped tags first so a
49+
// repo that also tags non-releases still resolves; retry unfiltered for
50+
// repos whose release tags carry no `v` prefix.
51+
for (const cmd of [
52+
`git describe --tags --abbrev=0 --match "v[0-9]*"`,
53+
`git describe --tags --abbrev=0`,
54+
]) {
55+
const tag = await run(cmd);
56+
if (!tag) continue;
57+
const version = stripV(tag);
58+
if (looksLikeVersion(version)) {
59+
return { release: version, source: `git tag ${tag}` };
60+
}
61+
}
62+
return undefined;
63+
};

src/commands/deploy/remote/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface DeployRemoteProps {
99
api: FaableApi;
1010
app: FaableApp;
1111
git: Awaited<ReturnType<typeof git_context>>;
12+
/** Proposed release version (see release_version.ts); omitted when unknown. */
13+
release?: string;
1214
workdir: string;
1315
}
1416

@@ -26,7 +28,7 @@ export interface DeployRemoteProps {
2628
export const deploy_remote = async (
2729
props: DeployRemoteProps
2830
): Promise<{ id: string }> => {
29-
const { api, app, git, workdir } = props;
31+
const { api, app, git, release, workdir } = props;
3032

3133
log.info(`☁️ Remote build`);
3234

@@ -39,6 +41,7 @@ export const deploy_remote = async (
3941
const deployment = await api.createDeployment({
4042
app_id: app.id,
4143
source: { manifest },
44+
...(release ? { release } : {}),
4245
...git,
4346
});
4447

0 commit comments

Comments
 (0)