summaryrefslogtreecommitdiff
path: root/internal/release/release.go
blob: a40cfb1d6c326c829b99895ee80f06d5cab74c3a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Package release runs an app's own release-phase Makefile target, if it
// declares one - mirroring Heroku's Procfile `release:` line, but as a
// `release` target in the same Makefile that builds the app. Skiff never
// interprets what the target does (run migrations, warm a cache,
// whatever); it only checks whether one is declared and gates the deploy
// on its exit code.
package release

import (
	"context"
	"fmt"
	"io"
	"strings"

	"skiff/internal/sh"
)

// notDeclared is bmake's exact wording (verified against FreeBSD 15's
// /usr/bin/make) when asked to build a target with no matching rule -
// explicit, implicit, or file-on-disk. Skiff is FreeBSD-only, so this
// doesn't need to also cover GNU make's differently-worded message.
const notDeclared = "don't know how to make release"

// Declared reports whether dir's Makefile defines a `release` target, by
// asking bmake itself via a dry run rather than parsing Makefile syntax -
// robust regardless of how the target is actually defined (directly, via
// an include, a conditional, a pattern rule). dir is the release's
// host-side checkout; this runs on the host, before the jail is involved.
func Declared(ctx context.Context, dir string) (bool, error) {
	out, err := sh.CombinedOutput(ctx, dir, "make", "-n", "release")
	if err == nil {
		return true, nil
	}
	if strings.Contains(out, notDeclared) {
		return false, nil
	}
	return false, fmt.Errorf("make -n release: %w\n%s", err, out)
}

// releaseEnvWrapper sources /app/env (if present) before running the
// release recipe, so config vars set via `skiff config` reach the release
// phase the same way they reach the running app. $1 is the release's own
// checkout, passed positionally rather than string-interpolated so there's
// no manual shell-quoting surface.
const releaseEnvWrapper = `if [ -r /app/env ]; then set -a; . /app/env; set +a; fi; exec make -B -C "$1" release`

// Run executes the `release` Makefile target as a one-off inside app's
// already-running jail (the jail holds the process's filesystem and any
// state the target needs to reach - e.g. the app's own data.db).
// releaseDirInJail must be the new release's own checkout, jail-relative -
// not `current`, which still points at the previous release until cutover
// happens after this returns successfully. -B is bmake's compatibility/
// execution-mode flag, not an unconditional-rebuild flag; what actually
// guarantees the recipe always runs is `.PHONY: release` in the app's own
// Makefile. A non-nil error means the deploy must abort before
// restart/cutover.
func Run(ctx context.Context, out io.Writer, app, releaseDirInJail string) error {
	fmt.Fprintf(out, "==> release: make release (in %s)\n", releaseDirInJail)
	return sh.Run(ctx, out, "", "jexec", app, "sh", "-c", releaseEnvWrapper, "sh", releaseDirInJail)
}