516685526c
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
493 lines
23 KiB
YAML
493 lines
23 KiB
YAML
# Continuous integration.
|
|
#
|
|
# Runs on every push and pull request. The job is deliberately one sequence
|
|
# rather than a fan-out: this is a small project, the whole thing takes a
|
|
# couple of minutes, and a single log is easier to read than five.
|
|
#
|
|
# What it actually proves, in order of how likely each is to catch something:
|
|
#
|
|
# 1. Every package typechecks.
|
|
# 2. The migration chain applies to a REAL, empty Postgres. This has already
|
|
# caught one migration that Drizzle generated but Postgres refused
|
|
# (a jsonb -> integer cast with no USING clause).
|
|
# 3. The seed is idempotent — running it twice leaves the same row counts.
|
|
# This caught a seed that silently duplicated 27 contacts.
|
|
# 4. The unit tests pass.
|
|
# 5. The server boots against that database and answers.
|
|
# 6. Piggy boots against that same database, answers /internal/health, and
|
|
# the API — wired to it through the environment, not through a stub —
|
|
# reports it enabled. The relay's own tests inject a resolver, so they
|
|
# stay green whether or not the real wiring exists; only this step reads
|
|
# it. A crash on boot and an unset PIGGY_INTERNAL_URL look identical from
|
|
# the browser: the dock simply never appears.
|
|
# 7. The front end builds, and the CSP hash for the inline theme script still
|
|
# matches what the proxy is configured to allow. Editing that script
|
|
# changes its hash, and the failure mode is a silent white flash for
|
|
# dark-mode users rather than an error.
|
|
# 8. docker-compose.yml renders, and the piggy service is passed every
|
|
# environment key the worker's schema requires. That is the one failure
|
|
# nothing else here can see, because it lives between two files that are
|
|
# each individually correct.
|
|
#
|
|
# THE CSP HASH IS DUPLICATED IN THREE PLACES: the `expected` constant below,
|
|
# `deploy/Caddyfile.example`, and the LIVE Caddyfile on cloud-2. Only the first
|
|
# two are checked by anything. The live one is the copy that actually decides
|
|
# whether a browser runs the script, and nothing in this repository can see it,
|
|
# so changing the script means editing all three by hand — see deploy/README.md.
|
|
#
|
|
# Shipping is a two-step, and the second step is a human:
|
|
#
|
|
# push to main -> `verify` only. Nothing is published, nothing deploys.
|
|
# tag release-* -> `verify`, then `publish` pushes the image to the Gitea
|
|
# registry. The production host notices it and deploys.
|
|
#
|
|
# So the tag IS the ship decision. No credential on this runner can reach
|
|
# cloud-2; the host pulls, the runner never pushes to it.
|
|
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
# A tag push runs the same verification and then, and only then, publishes.
|
|
tags: ['release-*']
|
|
pull_request:
|
|
|
|
jobs:
|
|
verify:
|
|
runs-on: ubuntu-latest
|
|
|
|
# Postgres is started as a step rather than through `services:`, because
|
|
# this runner is configured with `container.network: host`.
|
|
#
|
|
# That single setting explains three failed attempts, and is worth writing
|
|
# down so nobody repeats them:
|
|
#
|
|
# `services:` Service containers are not resolvable by
|
|
# name from a host-networked job, giving
|
|
# "getaddrinfo EAI_AGAIN postgres".
|
|
# `--network container:$HOSTNAME` /etc/hostname reports the HOST's name,
|
|
# not a container id, so the namespace
|
|
# join finds no such container.
|
|
# default-gateway addressing The wrong idea entirely: with host
|
|
# networking the default route is the
|
|
# real router, not a docker bridge.
|
|
#
|
|
# Because the job shares the host's network namespace, a published port is
|
|
# simply on 127.0.0.1. The port is derived from the run id so concurrent
|
|
# runs cannot collide.
|
|
env:
|
|
PG_CONTAINER: pig-ci-pg-${{ github.run_id }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
|
|
# Corepack ships with Node and installs the exact pnpm pinned by
|
|
# `packageManager` in package.json, so CI, the image and a laptop all run
|
|
# the same version. `--activate` puts it on PATH; the download prompt is
|
|
# disabled because a non-interactive runner cannot answer it and would
|
|
# otherwise hang until the job times out.
|
|
- name: Enable pnpm
|
|
env:
|
|
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
|
|
run: |
|
|
corepack enable
|
|
corepack prepare --activate
|
|
pnpm --version
|
|
|
|
- name: Start Postgres
|
|
run: |
|
|
PG_PORT=$(( 45000 + (${{ github.run_id }} % 15000) ))
|
|
echo "Publishing Postgres on 127.0.0.1:${PG_PORT}"
|
|
|
|
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
|
docker run -d --name "$PG_CONTAINER" \
|
|
-p "127.0.0.1:${PG_PORT}:5432" \
|
|
-e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
|
|
postgres:16-alpine
|
|
|
|
# pg_isready inside the container only proves the server started.
|
|
# What matters is that THIS job can reach it through the published
|
|
# port, so the readiness check is made from here, over TCP.
|
|
for i in $(seq 1 60); do
|
|
if node -e "
|
|
const net=require('net');
|
|
const s=net.connect(${PG_PORT},'127.0.0.1');
|
|
s.on('connect',()=>{s.end();process.exit(0)});
|
|
s.on('error',()=>process.exit(1));
|
|
" 2>/dev/null; then
|
|
echo "Reachable after ${i}s"
|
|
echo "DATABASE_URL=postgres://pig:pig@127.0.0.1:${PG_PORT}/pig" >> "$GITHUB_ENV"
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Postgres never became reachable on 127.0.0.1:${PG_PORT}"
|
|
docker logs "$PG_CONTAINER" 2>&1 | tail -30
|
|
exit 1
|
|
|
|
- name: Install
|
|
# --frozen-lockfile fails rather than quietly resolving a different
|
|
# tree when the lockfile and manifests disagree. That is the whole
|
|
# point of committing a lockfile, and it is the default in CI anyway —
|
|
# stated here so it survives someone running this locally.
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Typecheck every package
|
|
run: pnpm run typecheck
|
|
|
|
- name: Unit tests
|
|
run: pnpm run test
|
|
|
|
- name: Migrations apply to a real Postgres
|
|
run: pnpm exec tsx packages/db/src/migrate.ts
|
|
|
|
- name: Migrations are re-runnable
|
|
run: pnpm exec tsx packages/db/src/migrate.ts
|
|
|
|
- name: Seed is idempotent
|
|
# A seed that duplicates on a second run corrupts any database it is
|
|
# pointed at twice, and nobody notices until the counts look odd.
|
|
#
|
|
# Two tables are counted, not one. This gate only ever watched
|
|
# `contacts`, and `contacts` is idempotent by an explicit existence
|
|
# check — so it could not see the class of regression it exists to
|
|
# catch, which is `onConflictDoNothing` firing at a constraint that is
|
|
# no longer there. The Motion starter library relies on exactly that
|
|
# clause against `motion_templates_slug_version_key`, so it is counted
|
|
# here too. Any table whose idempotency rests on a conflict target
|
|
# belongs in this list.
|
|
run: |
|
|
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
|
|
count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from $1"; }
|
|
CONTACTS_BEFORE=$(count contacts)
|
|
TEMPLATES_BEFORE=$(count motion_templates)
|
|
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
|
|
CONTACTS_AFTER=$(count contacts)
|
|
TEMPLATES_AFTER=$(count motion_templates)
|
|
echo "contacts: $CONTACTS_BEFORE -> $CONTACTS_AFTER"
|
|
echo "motion_templates: $TEMPLATES_BEFORE -> $TEMPLATES_AFTER"
|
|
test "$CONTACTS_BEFORE" = "$CONTACTS_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
|
|
test "$TEMPLATES_BEFORE" = "$TEMPLATES_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
|
|
|
|
- name: Critical path E2E against Postgres and Hono
|
|
run: pnpm run test:e2e
|
|
|
|
- name: Server boots and answers
|
|
run: |
|
|
NODE_ENV=development PIG_PORT=8930 pnpm exec tsx apps/api/src/server.ts &
|
|
for i in $(seq 1 30); do
|
|
curl -sf http://127.0.0.1:8930/api/health && break
|
|
sleep 1
|
|
done
|
|
curl -sf http://127.0.0.1:8930/api/health | grep -q '"ok":true'
|
|
|
|
- name: Piggy boots, and the API reports it enabled
|
|
# apps/api's own comment admits the gap this closes: its tests inject a
|
|
# resolver, so they pass whether or not the process is really wired to a
|
|
# Piggy. Here the relay is given nothing but environment variables and
|
|
# has to reach a Piggy that actually booted.
|
|
#
|
|
# It runs after the seed on purpose: with no identity provider every
|
|
# request is the development user, and that user is a seeded row.
|
|
run: |
|
|
LOGS=$(mktemp -d)
|
|
# Derived from the run id for the same reason Postgres's port is: this
|
|
# job shares the host's network namespace, so a fixed port belongs to
|
|
# the whole machine and two concurrent runs would fight over it.
|
|
PIGGY_PORT=$(( 30000 + (${{ github.run_id }} % 5000) ))
|
|
API_PORT=$(( 36000 + (${{ github.run_id }} % 5000) ))
|
|
# Worthless, and long enough for the schema's 32-character minimum.
|
|
INTERNAL_TOKEN='piggy-ci-internal-token-0123456789'
|
|
|
|
PIGGY_PID=''
|
|
API_PID=''
|
|
# There are two processes between the job's pid and the server that
|
|
# holds the port — pnpm launches tsx, tsx launches node — so the whole
|
|
# descendant tree has to go. Verified by watching a plain `kill` leave
|
|
# a Piggy behind, still holding its Postgres connections.
|
|
#
|
|
# SIGKILL, not the polite signal: nothing here needs a clean shutdown,
|
|
# and a server still listening when the next step runs is worse than
|
|
# an abrupt one.
|
|
stop() {
|
|
for pid in "$@"; do
|
|
[ -n "$pid" ] || continue
|
|
for child in $(pgrep -P "$pid" 2>/dev/null); do stop "$child"; done
|
|
kill -9 "$pid" 2>/dev/null || true
|
|
done
|
|
}
|
|
trap 'stop "$PIGGY_PID" "$API_PID"' EXIT
|
|
|
|
# Nothing here calls a model: the task queue is empty and the status
|
|
# route never reaches one. The inference base points at the discard
|
|
# port so that a future version which DID call out would fail loudly
|
|
# rather than quietly billing somebody's real endpoint.
|
|
PIGGY_INFERENCE_API_KEY=ci-stub-key \
|
|
PIGGY_INFERENCE_BASE=http://127.0.0.1:9/v1 \
|
|
PIGGY_INTERNAL_TOKEN="$INTERNAL_TOKEN" \
|
|
PIGGY_CHAT_HOST=127.0.0.1 \
|
|
PIGGY_CHAT_PORT="$PIGGY_PORT" \
|
|
pnpm exec tsx apps/piggy/src/main.ts > "$LOGS/piggy.log" 2>&1 &
|
|
PIGGY_PID=$!
|
|
|
|
for i in $(seq 1 30); do
|
|
curl -sf "http://127.0.0.1:${PIGGY_PORT}/internal/health" >/dev/null && break
|
|
sleep 1
|
|
done
|
|
HEALTH=$(curl -sf "http://127.0.0.1:${PIGGY_PORT}/internal/health" || true)
|
|
echo "GET /internal/health -> ${HEALTH:-<no response>}"
|
|
case "$HEALTH" in
|
|
*'"ok":true'*) ;;
|
|
*)
|
|
echo 'Piggy never answered. Its configuration schema rejects an incomplete environment on start, so the reason is usually the last line here:'
|
|
tail -30 "$LOGS/piggy.log"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
NODE_ENV=development PIG_PORT="$API_PORT" \
|
|
PIGGY_ENABLED=true \
|
|
PIGGY_INTERNAL_URL="http://127.0.0.1:${PIGGY_PORT}" \
|
|
PIGGY_INTERNAL_TOKEN="$INTERNAL_TOKEN" \
|
|
pnpm exec tsx apps/api/src/server.ts > "$LOGS/api.log" 2>&1 &
|
|
API_PID=$!
|
|
|
|
for i in $(seq 1 30); do
|
|
curl -sf "http://127.0.0.1:${API_PORT}/api/health" >/dev/null && break
|
|
sleep 1
|
|
done
|
|
|
|
# The stored admin toggle is the inner gate, and an earlier step in
|
|
# this job has already created the settings row with Piggy off — the
|
|
# insert is ON CONFLICT DO NOTHING, so booting with PIGGY_ENABLED=true
|
|
# cannot correct it. Flip it here: what is under test is the wiring,
|
|
# not the switch.
|
|
docker exec "$PG_CONTAINER" psql -U pig -d pig \
|
|
-c 'update platform_settings set piggy_enabled = true' >/dev/null
|
|
|
|
STATUS=$(curl -sf "http://127.0.0.1:${API_PORT}/api/piggy/status" || true)
|
|
echo "GET /api/piggy/status -> ${STATUS:-<no response>}"
|
|
case "$STATUS" in
|
|
*'"enabled":true'*) ;;
|
|
*)
|
|
echo 'The API does not consider Piggy available, which is what the browser sees as a dock that never appears. PIGGY_ENABLED, PIGGY_INTERNAL_URL and PIGGY_INTERNAL_TOKEN are all read where the routes are composed; one of them is no longer reaching them.'
|
|
tail -30 "$LOGS/api.log"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
- name: Front end builds
|
|
run: pnpm -F @pig/web run build
|
|
|
|
- name: Inline theme script still matches the deployed CSP hash
|
|
# The proxy allows exactly one inline script by hash. If the script
|
|
# changes and the CSP is not updated, dark-mode users get a white flash
|
|
# on every load and nothing anywhere reports an error.
|
|
run: |
|
|
node -e "
|
|
const fs=require('fs'), crypto=require('crypto');
|
|
const html=fs.readFileSync('apps/web/dist/index.html','utf8');
|
|
const m=html.match(/<script>([\s\S]*?)<\/script>/);
|
|
if(!m){ console.error('No inline script found in index.html'); process.exit(1); }
|
|
const hash='sha256-'+crypto.createHash('sha256').update(m[1]).digest('base64');
|
|
const expected='sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU=';
|
|
if(hash!==expected){
|
|
console.error('Inline script hash changed.');
|
|
console.error(' now: '+hash);
|
|
console.error(' expected: '+expected);
|
|
console.error('Update the CSP in deploy/Caddyfile.example AND on the server,');
|
|
console.error('then update the expected hash in this workflow.');
|
|
process.exit(1);
|
|
}
|
|
console.log('CSP hash unchanged: '+hash);
|
|
"
|
|
|
|
- name: Compose file renders, and Piggy is passed every key it requires
|
|
# `docker compose config` is the only thing that reads docker-compose.yml
|
|
# in this repository. Without it, a typo in that file is discovered by
|
|
# the production host, at deploy time, as a container that restarts for
|
|
# ever with a message only `docker logs` shows.
|
|
run: |
|
|
WORK=$(mktemp -d)
|
|
|
|
# A dummy environment file rather than a real .env: these values are
|
|
# never used, they exist only because compose refuses to render while
|
|
# a `${VAR:?}` is unset. Passing --env-file also means a stray .env on
|
|
# the runner cannot supply a key and hide its absence from the check.
|
|
cat > "$WORK/dummy.env" <<'ENVEOF'
|
|
POSTGRES_PASSWORD=ci-dummy
|
|
PIG_PUBLIC_URL=http://localhost:8920
|
|
SUPABASE_URL=http://localhost:54321
|
|
SUPABASE_ANON_KEY=ci-dummy
|
|
ENVEOF
|
|
|
|
# --profile piggy, because a profiled service is otherwise omitted
|
|
# from the rendered output entirely — and it is the service under test.
|
|
docker compose --env-file "$WORK/dummy.env" --profile piggy config -q || {
|
|
echo 'If that complained about a missing variable, add it to the dummy environment above: a `${VAR:?}` in docker-compose.yml needs a value here, not the right value.'
|
|
exit 1
|
|
}
|
|
docker compose --env-file "$WORK/dummy.env" --profile piggy config --format json \
|
|
> "$WORK/compose.json"
|
|
|
|
# .mts, not .ts: this file lives outside the workspace, so tsx has no
|
|
# package.json to tell it the module system and would treat a .ts file
|
|
# as CommonJS, where top-level await is a syntax error.
|
|
cat > "$WORK/piggy-env-keys.mts" <<'CHECKEOF'
|
|
/**
|
|
* Every key the Piggy configuration schema requires must be handed to
|
|
* the piggy service by docker-compose.yml. One that is missing is not
|
|
* a failure anywhere else in this repository: both files are
|
|
* individually valid, and the gap only appears as a container exiting
|
|
* on boot with "Invalid Piggy configuration".
|
|
*
|
|
* Both sides are read at run time — the required keys by asking the
|
|
* schema itself what an empty environment is missing, the provided
|
|
* keys from the compose file as Compose renders it. A list copied
|
|
* into this workflow would be right today and wrong by the next key.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
interface RenderedCompose {
|
|
services?: Record<string, { environment?: Record<string, string | null> }>;
|
|
}
|
|
|
|
const composeJsonPath = process.argv[2];
|
|
if (!composeJsonPath) {
|
|
console.error('Usage: piggy-env-keys.mts <rendered-compose.json>');
|
|
process.exit(1);
|
|
}
|
|
|
|
const configModule = (await import(
|
|
pathToFileURL(resolve('apps/piggy/src/config.ts')).href
|
|
)) as { loadPiggyConfig: (env: NodeJS.ProcessEnv) => unknown };
|
|
|
|
/**
|
|
* An empty environment fails on exactly the keys that have neither a
|
|
* default nor `.optional()`, and loadPiggyConfig reports one indented
|
|
* "KEY: message" line per failure.
|
|
*/
|
|
function keysWithNoDefault(): string[] {
|
|
try {
|
|
configModule.loadPiggyConfig({});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return [...message.matchAll(/^\s+([A-Z][A-Z0-9_]*):/gm)].flatMap(([, key]) =>
|
|
key ? [key] : [],
|
|
);
|
|
}
|
|
throw new Error(
|
|
'The Piggy config schema accepted an empty environment, so this check can no longer tell which keys are required.',
|
|
);
|
|
}
|
|
|
|
const rendered = JSON.parse(readFileSync(composeJsonPath, 'utf8')) as RenderedCompose;
|
|
const piggy = rendered.services?.piggy;
|
|
if (!piggy) {
|
|
console.error('The rendered compose file has no `piggy` service.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const provided = new Set(Object.keys(piggy.environment ?? {}));
|
|
const required = keysWithNoDefault();
|
|
console.log(`piggy requires ${required.length} key(s) with no default: ${required.join(', ')}`);
|
|
|
|
const missing = required.filter((key) => !provided.has(key));
|
|
if (missing.length > 0) {
|
|
console.error(`docker-compose.yml never passes: ${missing.join(', ')}`);
|
|
console.error('The piggy container would exit on boot and restart for ever.');
|
|
console.error("Add each key to the piggy service's environment: block, and to .env.example.");
|
|
process.exit(1);
|
|
}
|
|
console.log('Every required Piggy key is present in the piggy service.');
|
|
CHECKEOF
|
|
|
|
pnpm exec tsx "$WORK/piggy-env-keys.mts" "$WORK/compose.json"
|
|
|
|
- name: Docker image builds
|
|
run: docker build -t pig:ci .
|
|
|
|
- name: Stop Postgres
|
|
if: always()
|
|
run: docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
|
|
|
# Publish the image that production will run.
|
|
#
|
|
# Only on a `release-*` tag. A push to main proves the commit is sound and
|
|
# stops there; tagging is the deliberate, human act that says "ship this".
|
|
# The production host polls the registry for the newest release tag and
|
|
# deploys it (scripts/autodeploy.sh) — which is how this gets automated
|
|
# WITHOUT the thing scripts/deploy.sh refuses to do. Nothing here holds a
|
|
# credential for cloud-2, and nothing here can execute anything on cloud-2.
|
|
#
|
|
# `gitea.ref` and `github.ref` are the same object in Gitea Actions; the
|
|
# gitea-prefixed spelling is used for the ref test because that is the one
|
|
# documented for tag conditions, and github.* elsewhere to match the job
|
|
# above.
|
|
publish:
|
|
needs: verify
|
|
if: startsWith(gitea.ref, 'refs/tags/release-')
|
|
runs-on: ubuntu-latest
|
|
|
|
env:
|
|
REGISTRY: git.karti.ai
|
|
# Gitea namespaces packages under the lowercased owner, so PIG/pig is
|
|
# published as pig/pig.
|
|
IMAGE: git.karti.ai/pig/pig
|
|
# THE POINT OF THIS VARIABLE: the shared act_runner on cloud-1 runs with
|
|
# `container.network: host`, and its docker config is visible to jobs
|
|
# from every other repository on that host. A plain `docker login` would
|
|
# leave a credential in ~/.docker/config.json that any of them could
|
|
# read. Pointing DOCKER_CONFIG at a per-run directory keeps the token out
|
|
# of the shared file entirely; the logout step below is the second belt.
|
|
DOCKER_CONFIG: /tmp/pig-docker-${{ github.run_id }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Log in to the Gitea registry
|
|
# The per-run Actions token, not a long-lived secret: it is minted for
|
|
# this run and dies with it. --password-stdin because an argument is
|
|
# visible in the runner's process list to anything else on that host.
|
|
run: |
|
|
mkdir -p "$DOCKER_CONFIG"
|
|
printf '%s' '${{ secrets.GITHUB_TOKEN }}' \
|
|
| docker login "$REGISTRY" -u '${{ github.actor }}' --password-stdin
|
|
|
|
- name: Build and push
|
|
# Both cloud-1 and cloud-2 are aarch64, so this is a native build and
|
|
# needs no --platform. The layer cache from the `verify` job's
|
|
# `docker build` is warm on this same daemon, so the rebuild is cheap.
|
|
#
|
|
# Two tags, always pushed together: the tag is what a human asked for,
|
|
# the short sha is what is unambiguous a year later when tags have been
|
|
# moved or deleted.
|
|
run: |
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
SHORT_SHA=$(printf '%s' "${{ github.sha }}" | cut -c1-7)
|
|
echo "Publishing $IMAGE:$TAG and $IMAGE:$SHORT_SHA"
|
|
|
|
docker build -t "$IMAGE:$TAG" -t "$IMAGE:$SHORT_SHA" .
|
|
docker push "$IMAGE:$TAG"
|
|
docker push "$IMAGE:$SHORT_SHA"
|
|
|
|
# Print the digest: it is what the host poller compares against, and
|
|
# the only identifier that cannot be reassigned.
|
|
docker image inspect "$IMAGE:$TAG" \
|
|
--format '{{range .RepoDigests}}{{println .}}{{end}}'
|
|
|
|
- name: Log out
|
|
if: always()
|
|
# Runs even when the build failed, because a failed job that left a
|
|
# credential behind is exactly the leak this is guarding against.
|
|
run: |
|
|
docker logout "$REGISTRY" || true
|
|
rm -rf "$DOCKER_CONFIG"
|