mesh/merge-gate head 6f13813a59dc merged into trunk 0d11b4fc930d — fail: head 6f13813a59dc does not merge cleanly into trunk 0d11b4fc930d: in conflict inte…
mesh/repo-check head 6f13813a59dc merged into trunk 0d11b4fc930d — fail: head 6f13813a59dc does not merge cleanly into trunk 0d11b4fc930d: in conflict inte…
mesh/delivery-group group fix/121-genesis-package-registry rejected: its order contradicts itself: novox/mesh-catalog@e0bc919d4945 and novox/mesh-controller@a6…
mesh/delivery superseded: a newer head of the same pull request
The build agent declares neither the retired builder's npm-password own-secret nor a serves setting, so the packages step handed its credential to nothing and genesis's sdk step had no way to publish. The packages step now seals the genesis-package-registry own-secret to build-agent: the forge on this node's loopback, the port given for the package registry, its npm path, and the mesh-builder account with its password. setFoundationSettings no longer gives the builder a serves.port.
440 lines
21 KiB
Go
440 lines
21 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Raising the package registry, before the base is built.
|
|
//
|
|
// The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than
|
|
// cloning it from a git URL (novox/hq ADR 0076, issue 053). So the registry has to answer, and the
|
|
// SDK has to be in it, before the base build runs. That is a pivot like the control plane's: gitea's
|
|
// SERVER is raised directly here, on the foundation's own postgres, and adopted as an ordinary module
|
|
// only after the base exists (which is what lets its provisioner image — built on the base — run).
|
|
//
|
|
// Nothing here is the steady state. It is the smallest set of acts that puts a working npm registry
|
|
// in front of the base build: a database, a server, an admin, one org, the builder's own account,
|
|
// and the SDK published under it. The gitea MODULE, installed after the base, takes all of this over.
|
|
|
|
const (
|
|
// foundationStore is the foundation's postgres container — the mesh's own memory, raised from the
|
|
// bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051).
|
|
foundationStore = "mesh-store"
|
|
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module —
|
|
// under the name the gitea MODULE declares for its container, so the module finds it and holds
|
|
// it rather than raising a second forge beside it (novox/hq ADR 0163, rule 7; issue 090).
|
|
giteaBootstrap = "gitea"
|
|
// giteaImage is the image the gitea module declares for that container, pinned to the same
|
|
// digest, so taking the module over is not a downgrade and not an upgrade. **Moves with the
|
|
// module's pin**: the two are compared by a take, and a difference is said there — but a
|
|
// genesis that raised an older image than the module declares would be taken over as an
|
|
// upgrade on first push, which a forge holding the mesh's packages must not have done to it
|
|
// unannounced. Checked in TestGenesisRaisesTheForgeAsTheModuleDeclaresIt against the module's
|
|
// manifest where the catalogue is beside this checkout.
|
|
giteaImage = "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
|
|
// giteaDataDir is where the module's `data` directory resolves on a machine with the default
|
|
// layout (<data root>/<module>/<id>, novox/hq ADR 0112): mounted at /data as the module mounts
|
|
// it, so the repositories, attachments and indexes the bootstrap forge accumulates are the
|
|
// module's the day it is taken — before this, the forge had no volume and its data was the
|
|
// container's, lost with it.
|
|
giteaDataDir = "/var/lib/gitea/data"
|
|
// packagesOrg is the npm owner: every module consumes `@novox/*` from this gitea org.
|
|
packagesOrg = "novox"
|
|
// packagesTeam is the org team whose members may read and write the org's packages.
|
|
packagesTeam = "packages"
|
|
// giteaAdminUser is the admin the bootstrap creates and the provisioner later authenticates as.
|
|
giteaAdminUser = "mesh-admin"
|
|
// builderGiteaUser is the gitea account the build agent publishes and pulls with at genesis: the
|
|
// `as` of the genesis document (novox/hq ADR 0336 §3). Retiring it is issue 602.
|
|
builderGiteaUser = "mesh-builder"
|
|
// giteaDBRole/giteaDBName is gitea's own database in the foundation store.
|
|
giteaDBRole = "mesh_gitea"
|
|
giteaDBName = "mesh_gitea"
|
|
// defaultGiteaPort is where the raised server answers on the machine unless the node gave the
|
|
// package registry another port (novox/hq ADR 0100).
|
|
defaultGiteaPort = 3000
|
|
)
|
|
|
|
// ForgeModule is the module that owns the package registry — the one the bootstrap forge above is
|
|
// a stand-in for, and which takes it over once the base exists.
|
|
//
|
|
// Named here because the port given for the package registry is that module's setting on this node
|
|
// and not this installer's private number (novox/hq 04-ISSUES/085). What follows that setting is
|
|
// what the mesh derives from a module's ports: the forge's container mapping, its rule in the
|
|
// filter, its opening on an adopted node, and what it says it serves — so a consumer the mesh binds
|
|
// is told where the machine actually put the registry.
|
|
//
|
|
// What does NOT follow it, and is not this change's to fix: the address of itself the forge's
|
|
// runtime is given, which the catalogue writes as a literal (novox/hq 04-ISSUES/088), and the port
|
|
// in its route contribution. Both are already wrong for any machine port the mesh assigned, with a
|
|
// given port or without one.
|
|
const ForgeModule = "gitea"
|
|
|
|
// RaisePackageRegistry puts a working npm registry in front of the base build. It is idempotent:
|
|
// every step tolerates having been done, because genesis is safe to run again.
|
|
func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane,
|
|
say func(string)) error {
|
|
run := d.Run
|
|
|
|
// The passwords this pivot mints, kept once so a re-run is the same run: gitea already holds
|
|
// them, so regenerating would lock the mesh out of the forge it just raised.
|
|
dbPassword, adminPassword, builderPassword, err := packagePasswords()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" seeding gitea's database in the foundation store")
|
|
ports := o.Ports.orDefaults()
|
|
if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" raising the gitea server on that database")
|
|
if err := raiseGiteaServer(ctx, run, o.Timeout, dbPassword, ports, say); err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" waiting for gitea to answer")
|
|
base := fmt.Sprintf("http://127.0.0.1:%d", ports.Packages)
|
|
if err := waitForGitea(ctx, d, o, base, say); err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" creating the gitea admin")
|
|
if err := createGiteaAdmin(ctx, run, o.Timeout, adminPassword, say); err != nil {
|
|
return err
|
|
}
|
|
|
|
admin := &giteaAdmin{base: base, user: giteaAdminUser, password: adminPassword,
|
|
client: &http.Client{Timeout: o.Timeout}}
|
|
|
|
say(" ensuring the npm org, its package team, and the builder's account")
|
|
if err := admin.ensureOrg(ctx, packagesOrg); err != nil {
|
|
return err
|
|
}
|
|
teamID, err := admin.ensureTeam(ctx, packagesOrg, packagesTeam)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := admin.ensureUser(ctx, builderGiteaUser, builderPassword); err != nil {
|
|
return err
|
|
}
|
|
if err := admin.addToTeam(ctx, teamID, builderGiteaUser); err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" recording the port it was given as the forge module's own")
|
|
if err := recordTheForgesPort(ctx, o, control, say); err != nil {
|
|
return err
|
|
}
|
|
|
|
say(" sealing the build agent the genesis document for the forge it raised")
|
|
if err := deliverGenesisRegistry(ctx, o, control, genesisRegistry(ports, builderPassword), say); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// recordTheForgesPort makes the port given for the package registry a setting of the module that
|
|
// serves it, on this node (novox/hq 04-ISSUES/085, ADR 0100).
|
|
//
|
|
// **The forge is the one foundation port that was not a setting.** The store's, the bus's and the
|
|
// registry's each become a `ports` setting of the module that
|
|
// binds them, set where that module is registered and assigned; the forge is raised by hand here,
|
|
// long before its module can be built, so there was no registration to hang it on and the number
|
|
// lived in rewritten manifest text instead. So the manifest is registered here — not assigned, and
|
|
// nothing of it runs — for the one thing registering buys: a settings row needs the module row to
|
|
// exist. Whenever somebody later assigns the forge on this node, it comes up on the port this
|
|
// machine was given rather than on the catalogue's.
|
|
//
|
|
// **Registered only when the mesh does not already know it, which is the opposite of every other
|
|
// `module add` here.** The rest of the installer registers every run on purpose: the manifest is
|
|
// what changes between runs, and skipping it would pin the mesh to a previous image. This one
|
|
// changes nothing about the forge and wants nothing of the checkout's manifest — and registering
|
|
// is an overwrite, so a re-run of genesis pointed at an older catalogue would replace the manifest
|
|
// of a forge that is built and assigned, with a push a few lines later. The port is the only thing
|
|
// this is here to record, and the setting does not need the manifest to be this checkout's.
|
|
//
|
|
// A node given the catalogue's own port records nothing and registers nothing, so a genesis on the
|
|
// defaults does exactly what it did before.
|
|
func recordTheForgesPort(ctx context.Context, o Options, control controlPlane,
|
|
say func(string)) error {
|
|
if o.Ports.orDefaults().Packages == DefaultPorts().Packages {
|
|
return nil
|
|
}
|
|
known, err := control.tell(ctx, "module", "list")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mentions(known, ForgeModule) {
|
|
say(" known " + ForgeModule + " — left as the mesh has it; only its port is set here")
|
|
} else {
|
|
manifest, err := readManifest(o.Catalogue, ForgeModule)
|
|
if err != nil {
|
|
return fmt.Errorf("%w\n"+
|
|
"This is the module that owns the forge genesis just raised. Without it the port this "+
|
|
"machine was given for the package registry is nobody's setting, and taking the forge "+
|
|
"over would put it back on the catalogue's port", err)
|
|
}
|
|
remote := "/" + ForgeModule + "-module.json"
|
|
if err := control.carrying(ctx, ForgeModule+"-module.json", manifest, remote); err != nil {
|
|
return err
|
|
}
|
|
if _, err := control.tell(ctx, "module", "add", remote); err != nil {
|
|
return err
|
|
}
|
|
say(" registered " + ForgeModule + " — registered, not assigned: nothing of it runs yet")
|
|
}
|
|
return setFoundationSettings(ctx, o, control, ForgeModule, say)
|
|
}
|
|
|
|
// seedGiteaDatabase creates gitea's role and database inside the foundation postgres, the same way
|
|
// the foundation creates its own — psql run through the store container (the map's Route B). The role
|
|
// is created before the database because the database is owned by it. Both are tolerant of already
|
|
// existing, so a re-run changes nothing.
|
|
func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, password string,
|
|
say func(string)) error {
|
|
asking, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Single statements through psql -c, not one script: CREATE DATABASE cannot run in a
|
|
// transaction and \gexec does not parse through -c. The password is base64url, so it carries no
|
|
// quote or backslash to escape inside a SQL literal.
|
|
psql := func(sql string) (string, error) {
|
|
return run(asking, "docker", "exec", foundationStore, "psql", "-U", "postgres", "-tAc", sql)
|
|
}
|
|
|
|
// The role: create it, and if it is already there (create fails) reset its password so a re-run
|
|
// converges on this run's credential.
|
|
create := fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password)
|
|
if _, err := psql(create); err != nil {
|
|
alter := fmt.Sprintf("ALTER ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password)
|
|
if _, err := psql(alter); err != nil {
|
|
return fmt.Errorf("could not create gitea's role in %s: %w", foundationStore, err)
|
|
}
|
|
}
|
|
|
|
// The database: created only if absent, because CREATE DATABASE has no IF NOT EXISTS and a
|
|
// second create is an error rather than a no-op.
|
|
present, err := psql(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", giteaDBName))
|
|
if err != nil {
|
|
return fmt.Errorf("could not check for gitea's database in %s: %w", foundationStore, err)
|
|
}
|
|
if strings.TrimSpace(present) != "1" {
|
|
if _, err := psql(fmt.Sprintf("CREATE DATABASE %s OWNER %s", giteaDBName, giteaDBRole)); err != nil {
|
|
return fmt.Errorf("could not create gitea's database in %s: %w", foundationStore, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// raiseGiteaServer starts the gitea server container against the foundation store. It runs on the
|
|
// machine's own network, so `127.0.0.1` reaches the store where it publishes its port, and it binds
|
|
// its own port there for the builder and this installer. Started if absent, left alone if present.
|
|
func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, dbPassword string,
|
|
ports FoundationPorts, say func(string)) error {
|
|
asking, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Already there: a re-run does not raise a second one. `docker start` is a no-op on a running
|
|
// container and revives a stopped one. `container inspect`, not the bare form: a name is not
|
|
// unique across object kinds, and `.Id` resolves on a network or volume too.
|
|
if out, _ := run(asking, "docker", "container", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
|
|
_, _ = run(asking, "docker", "start", giteaBootstrap)
|
|
return nil
|
|
}
|
|
|
|
env := []string{
|
|
"-e", "GITEA__database__DB_TYPE=postgres",
|
|
// The store is reached on the shared network namespace's loopback.
|
|
"-e", fmt.Sprintf("GITEA__database__HOST=127.0.0.1:%d", ports.Store),
|
|
"-e", "GITEA__database__NAME=" + giteaDBName,
|
|
"-e", "GITEA__database__USER=" + giteaDBRole,
|
|
"-e", "GITEA__database__PASSWD=" + dbPassword,
|
|
// Skip the install wizard: the mesh configures gitea, not a person at a browser.
|
|
"-e", "GITEA__security__INSTALL_LOCK=true",
|
|
// The forge answers on the machine's loopback, and its own links must say so: gitea's
|
|
// package metadata hands npm a tarball URL built from ROOT_URL, and a client only sends its
|
|
// stored credential to the host it was stored for. A default ROOT_URL of localhost is a
|
|
// different host than the binding's 127.0.0.1, so the credential would not be sent.
|
|
"-e", fmt.Sprintf("GITEA__server__ROOT_URL=http://127.0.0.1:%d/", ports.Packages),
|
|
"-e", "USER_UID=1000", "-e", "USER_GID=1000",
|
|
}
|
|
if ports.Packages != defaultGiteaPort {
|
|
// On the machine's network the server binds its own port, so a port given for it is
|
|
// the one it is told to listen on.
|
|
env = append(env, "-e", fmt.Sprintf("GITEA__server__HTTP_PORT=%d", ports.Packages))
|
|
}
|
|
args := append([]string{
|
|
"run", "-d", "--name", giteaBootstrap,
|
|
// Host network, like the control plane: it reaches the foundation store on the machine's
|
|
// loopback (where the store publishes 5432) and answers on the machine's own 3000, which is
|
|
// where mesh-bootstrap and the builder's build containers look for it. The module runs
|
|
// bridged and publishes its ports; that is the one difference a take still has to say
|
|
// (ADR 0163, rule 7) — the data, the name and the image are the module's already.
|
|
"--network", "host",
|
|
"--restart", "unless-stopped",
|
|
// The module's data directory, so what the forge accumulates is the module's when taken.
|
|
"--volume", giteaDataDir + ":/data",
|
|
}, env...)
|
|
args = append(args, giteaImage)
|
|
|
|
if _, err := run(asking, "docker", args...); err != nil {
|
|
return fmt.Errorf("could not raise the gitea server: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitForGitea polls gitea's version endpoint until it answers or the wait runs out. A container
|
|
// that is up is not a forge that serves; `/api/v1/version` is the question whose answer means it is.
|
|
func waitForGitea(ctx context.Context, d Deps, o Options, base string, say func(string)) error {
|
|
deadline := time.Now().Add(o.Wait)
|
|
url := base + "/api/v1/version"
|
|
for {
|
|
status, _, err := d.Fetch(ctx, url)
|
|
if err == nil && status == http.StatusOK {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("gitea did not answer at %s within %s", url, o.Wait)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
// createGiteaAdmin creates the mesh's gitea admin through the server's own CLI. Tolerant of the
|
|
// admin already existing, because a re-run must not fail on it — and it resets the password every
|
|
// run, so a rotated admin secret takes.
|
|
func createGiteaAdmin(ctx context.Context, run Runner, timeout time.Duration, password string,
|
|
say func(string)) error {
|
|
asking, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
// Create once, with the password kept across re-runs, and do not follow with change-password:
|
|
// change-password re-enables must-change-password, which then refuses every API call as
|
|
// "you must change your password". Tolerant of "already exists", because the kept password
|
|
// means the existing admin is already the one this run authenticates as.
|
|
create := fmt.Sprintf(
|
|
"gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false",
|
|
giteaAdminUser, giteaAdminUser, password)
|
|
if _, err := run(asking, "docker", "exec", "-u", "git", giteaBootstrap,
|
|
"sh", "-c", create+" || true"); err != nil {
|
|
return fmt.Errorf("could not create the gitea admin: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// packagePasswords loads the pivot's three passwords, minting and keeping them the first time. Kept
|
|
// on the machine because gitea, once raised, holds them: a second genesis that minted fresh ones
|
|
// would raise a forge it cannot then log into.
|
|
func packagePasswords() (db, admin, builder string, err error) {
|
|
const dir = "/var/lib/mesh/packages"
|
|
const file = dir + "/bootstrap.env"
|
|
if raw, e := os.ReadFile(file); e == nil {
|
|
vals := map[string]string{}
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
|
vals[k] = v
|
|
}
|
|
}
|
|
if vals["DB"] != "" && vals["ADMIN"] != "" && vals["BUILDER"] != "" {
|
|
return vals["DB"], vals["ADMIN"], vals["BUILDER"], nil
|
|
}
|
|
}
|
|
db, admin, builder = newPassword(), newPassword(), newPassword()
|
|
if err = os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
content := fmt.Sprintf("DB=%s\nADMIN=%s\nBUILDER=%s\n", db, admin, builder)
|
|
if err = os.WriteFile(file, []byte(content), 0o600); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return db, admin, builder, nil
|
|
}
|
|
|
|
// BuildAgentModule is the module genesis seals the genesis document to: the build agent, which holds
|
|
// the node seat `node-build-agent` (novox/hq ADR 0190).
|
|
const BuildAgentModule = "build-agent"
|
|
|
|
// GenesisRegistrySecret is the build agent's own-secret the genesis document is sealed as (novox/hq
|
|
// ADR 0336 §3). The build-agent manifest declares it, issued outside the mesh: only this installer
|
|
// gives one, on genesis's node.
|
|
const GenesisRegistrySecret = "genesis-package-registry"
|
|
|
|
// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): the forge this step raised, the port
|
|
// this node gave the package registry, its npm path, and the account this step made with its password.
|
|
// mesh-controller's cmd/mesh-builder reads it under the same field names while the build agent's
|
|
// npm-package-registry requirement is unbound, and prefers the binding once the gitea module is
|
|
// assigned and it binds.
|
|
type GenesisRegistry struct {
|
|
Scheme string `json:"scheme"`
|
|
At string `json:"at"`
|
|
Port int `json:"port"`
|
|
NpmPath string `json:"npm-path"`
|
|
As string `json:"as"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// genesisRegistry is the document for the forge raised here: on the machine's own loopback, where it
|
|
// answers on the host network and where its ROOT_URL says it is (raiseGiteaServer), on the port this
|
|
// node gave the package registry.
|
|
func genesisRegistry(ports FoundationPorts, password string) GenesisRegistry {
|
|
return GenesisRegistry{Scheme: "http", At: "127.0.0.1", Port: ports.Packages,
|
|
NpmPath: "/api/packages/" + packagesOrg + "/npm/", As: builderGiteaUser, Password: password}
|
|
}
|
|
|
|
// deliverGenesisRegistry seals the genesis document to the build agent on this node as its
|
|
// `genesis-package-registry` own-secret (novox/hq ADR 0336 §3), the same way the control plane's store
|
|
// connections are delivered — carry the value in, `secret accept`, and the next push writes it sealed
|
|
// where the build agent reads it.
|
|
//
|
|
// **The build agent's one way to the forge until the registry binds.** Its npm-package-registry
|
|
// requirement is optional (ADR 0336 §1), and nothing provides it until the gitea module is assigned
|
|
// after the base, so the sdk step (15) publishes and the base step (16) resolves with the account in
|
|
// this document. It replaces the retired builder's `npm-password` own-secret and the `serves.port`
|
|
// setting its carried binding took, neither of which the build agent declares.
|
|
func deliverGenesisRegistry(ctx context.Context, o Options, control controlPlane, doc GenesisRegistry,
|
|
say func(string)) error {
|
|
raw, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
at := "/accepting-" + GenesisRegistrySecret
|
|
if err := control.carryingSecret(ctx, "mesh-accepting-"+GenesisRegistrySecret, raw, at); err != nil {
|
|
return err
|
|
}
|
|
if _, err := control.tell(ctx, "secret", "accept", o.Node, BuildAgentModule, GenesisRegistrySecret, "--from", at); err != nil {
|
|
return err
|
|
}
|
|
// Push so the sealed document reaches the build agent, which reads it at its next build.
|
|
if _, err := control.tell(ctx, "push", o.Node); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PublishTheSDK dispatches a build of the SDK to the build agent. The agent has the genesis document by
|
|
// now (novox/hq ADR 0336 §7), so the build's `npm publish` authenticates; the artifact is a `package`, built
|
|
// on a public base, so this needs no toolchain — which is the whole point of doing it before the base.
|
|
func PublishTheSDK(ctx context.Context, o Options, control controlPlane, say func(string)) error {
|
|
if o.SDKSource.Repository == "" {
|
|
return fmt.Errorf("raising the registry needs --sdk-source: the SDK is built from its own " +
|
|
"repository, and an installer told nothing cannot know where that is")
|
|
}
|
|
say(" publishing " + o.SDKSource.Repository + " at " + refOr(o.SDKSource.Ref))
|
|
_, err := control.within(buildWait).tell(ctx,
|
|
"build", o.SDKSource.Repository, "--ref", refOr(o.SDKSource.Ref), "--wait", "1200s")
|
|
return err
|
|
}
|