The four-phase deploy engine.
just release-run TICKETis the operator surface; this page is what happens under the hood. Halt-on-failure, state-tracked, withrelease-revertas the atomic-rollback partner.
For the operator-facing decision tree, see /pma/cookbook/debug-a-failed-release.
Each phase is its own script under scripts/release/. Halt-on-failure
between phases. State file written between every phase so resume is safe.
scripts/release/prepare.sh
BASE_SHA = HEAD (the commit BEFORE pulling).git fetch origin.git pull --ff-only origin main (or the specified branch).MERGE_SHA = HEAD (after pull).BASE_SHA...MERGE_SHA to find which packages/*/ directories.asd/workspace/releases/<ticket>.env:TICKET=1234
BASE_SHA=abc123
MERGE_SHA=def456
SERVICES="redmine wikijs"
BACKUPS="redmine=20260518_153012 wikijs=20260518_153015"
Critical rule: the operator MUST NOT pre-pull before invoking
release-run. Pre-pulling collapses BASE_SHA == MERGE_SHA, the diff
returns "no services changed", and the script aborts with "nothing to
release". Recovery is git reflog + git reset --hard <pre-pull-sha> —
pollutes reflog and is error-prone.
This is Golden Rule 13 codified.
scripts/release/migrate.sh (regenerated from the diff every release)
For each service in SERVICES:
pre_start_init.ts if present.docker compose up -d --force-recreate <containers> — picks up newmanifest.health.endpoint until healthy or timeout.post_start_init.ts if present (idempotent config-apply).ensure-config.ts if present.If any step fails: halt, log the phase + service, exit non-zero.
releases/<ticket>-*.sh if it exists
Per-ticket migration. The framework can't know "for ticket #1234, after
the new code is live, run bench migrate" — that's domain knowledge.
The per-ticket script encodes it.
#!/usr/bin/env bash
# releases/1234-rebuild-search-index.sh
set -euo pipefail
echo "[#1234] Rebuilding Wiki.js search index..."
docker exec ${CONTAINER_PREFIX}wikijs node /wiki/server/rebuild-search.js
echo "✓ #1234 search index rebuilt."
Scripts are bash by convention; TypeScript also supported
(releases/<ticket>-*.ts run via bun).
If no per-ticket work is needed, omit the file. Phase 3 is a no-op.
scripts/release/verify.ts
Three classes of check:
manifest.image.name:manifest.image.tag.manifest.health.endpoint returnsmanifest.health.expected_code. Wrapped in retry-with-backoffe2e.enabled: true,All checks pass → release marked complete. Any fail → halt, log, the
release-revert path is available.
.asd/workspace/releases/<ticket>.env:
TICKET=1234
BASE_SHA=abc123
MERGE_SHA=def456
PHASE=verify
PHASE_STATUS=failed
SERVICES="redmine wikijs"
BACKUPS="redmine=20260518_153012 wikijs=20260518_153015"
TIMESTAMP_PREPARE_START=2026-05-18T15:29:00Z
TIMESTAMP_PREPARE_DONE=2026-05-18T15:30:12Z
TIMESTAMP_MIGRATE_START=2026-05-18T15:30:12Z
TIMESTAMP_MIGRATE_DONE=2026-05-18T15:34:00Z
TIMESTAMP_TICKET_START=2026-05-18T15:34:00Z
TIMESTAMP_TICKET_DONE=2026-05-18T15:34:05Z
TIMESTAMP_VERIFY_START=2026-05-18T15:34:05Z
TIMESTAMP_VERIFY_FAILED=2026-05-18T15:34:35Z
PHASE + PHASE_STATUS tell you where the release stopped.
SERVICES is the canonical list of "affected services for this release"
— used by --from= resumes (so phase 2 doesn't re-discover; it uses the
same list).
BACKUPS map each service to its phase-1 backup timestamp — used by
release-revert to know what to restore.
just release-run TICKET --from=PHASE skips earlier phases:
--from=migrate — uses existing state file's SERVICES + BACKUPS, runs--from=ticket — skips phases 1+2, runs ticket script + verify.--from=verify — re-runs verify only (useful after a flaky probe).State file must exist for --from= to work — provides the BASE_SHA,
SERVICES, BACKUPS context for downstream phases.
release-revertscripts/release/revert.sh:
BACKUPS + MERGE_SHA + BASE_SHA.BACKUPS: restore the backup.git revert -m 1 <MERGE_SHA> — revert the merge commit, get back togit push origin main.Two flags:
--skip-git — for hotfix branches that don't have a merge commit--skip-data — skip the backup restore. Use ONLY if the data wasFor releases from a non-main branch (urgent fix):
ssh prod 'cd /opt/asd-pma && just release-run 1234 hotfix/branch --force-allow-non-main'
# After confirming the hotfix is good:
ssh prod 'cd /opt/asd-pma && just release-return-to-main'
# Returns prod to main + verifies clean state.
return-to-main checks that the hotfix has been merged to main (via
PR), then ff-pulls main on prod.
Full hotfix lifecycle: docs/operations/RELEASE-HOTFIX-LIFECYCLE.md.
In addition to the state file, every phase appends to
.asd/workspace/releases/<ticket>.log:
2026-05-18T15:29:00Z ticket=1234 phase=prepare event=start
2026-05-18T15:30:12Z ticket=1234 phase=prepare event=done duration=72s
2026-05-18T15:30:12Z ticket=1234 phase=migrate event=start services="redmine wikijs"
2026-05-18T15:34:00Z ticket=1234 phase=migrate event=done duration=228s
2026-05-18T15:34:00Z ticket=1234 phase=ticket event=start script=releases/1234-rebuild-search.sh
2026-05-18T15:34:05Z ticket=1234 phase=ticket event=done duration=5s
2026-05-18T15:34:05Z ticket=1234 phase=verify event=start
2026-05-18T15:34:35Z ticket=1234 phase=verify event=failed rc=1
For post-mortems, the log tells you exactly which phase, which event,
which duration.
/pma/learn/06-release-and-rollback — the operator's walk through the four phases./pma/cookbook/debug-a-failed-release — decision tree when phase N fails./pma/reference/cli/release — all the release-* recipe flags./pma/internals/recovery-playbooks — for known release failure modes.docs/operations/RELEASE.md in the repo — full operator + verb decision table.