Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/publish-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ on:
required: false
default: false
type: boolean
executor_run_id:
description: >-
Publish Executor run id for this tag. Set by the automatic trigger;
the promote gate waits for exactly this run. Leave empty for manual
dispatches.
required: false
default: ""
type: string

permissions:
contents: read
Expand Down Expand Up @@ -231,6 +239,8 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
# Read Publish Executor run state for the promote gate below.
actions: read

steps:
- name: Checkout validation script
Expand Down Expand Up @@ -278,6 +288,52 @@ jobs:
| xargs -0 -n1 -P8 -I{} sh -c \
'echo "Uploading: $1"; exec gh release upload "$RELEASE_TAG" "$1" --repo "$GITHUB_REPOSITORY" --clobber' _ {}

# A published release must imply the npm packages for this tag exist.
# Publish Executor triggers this workflow before its npm publishes (to
# overlap them with the desktop build) and hands over its own run id,
# so wait for exactly that run to conclude successfully before going
# public. A run-name search would fail open whenever the run fell
# outside the listing window, so only an explicitly empty run id (a
# manual dispatch) skips the gate — the operator owns the invariant
# then.
- name: Wait for executor package publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EXECUTOR_RUN_ID: ${{ inputs.executor_run_id }}
run: |
set -euo pipefail
if [ -z "$EXECUTOR_RUN_ID" ]; then
echo "No executor_run_id given (manual dispatch); skipping the publish gate."
exit 0
fi
deadline=$((SECONDS + 1200))
while :; do
run=$(gh run view "$EXECUTOR_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json status,conclusion,url,displayTitle)
title=$(echo "$run" | jq -r .displayTitle)
if [ "$title" != "publish executor $RELEASE_TAG" ]; then
echo "Run $EXECUTOR_RUN_ID is '$title', not 'publish executor $RELEASE_TAG' — refusing to promote."
exit 1
fi
status=$(echo "$run" | jq -r .status)
if [ "$status" = "completed" ]; then
conclusion=$(echo "$run" | jq -r .conclusion)
if [ "$conclusion" = "success" ]; then
echo "Publish Executor succeeded."
break
fi
echo "Publish Executor concluded '$conclusion' — leaving the release in draft."
echo "$run" | jq -r .url
exit 1
fi
if [ "$SECONDS" -ge "$deadline" ]; then
echo "Timed out waiting for Publish Executor — leaving the release in draft."
exit 1
fi
echo "Publish Executor status: $status; waiting..."
sleep 15
done

# Flip draft → published only after every desktop asset is uploaded —
# this is the atomic point where the new tag becomes "latest".
- name: Promote release (draft → published)
Expand Down
21 changes: 18 additions & 3 deletions .github/workflows/publish-executor-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,34 @@ jobs:
- name: Run release checks
run: bun run release:check

- name: Publish package and create release
# --skip-build reuses the artifacts release:check's dry-run just built
# in this same tree (validated against the release version) instead of
# rebuilding everything a second time.
- name: Stage release artifacts and draft GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
export GITHUB_REF_TYPE=tag
export GITHUB_REF_NAME="$RELEASE_TAG"
export GITHUB_REF="refs/tags/$RELEASE_TAG"
bun run release:publish
bun run --cwd apps/cli src/release.ts --stage-only --skip-build

# Desktop needs only the tag and the draft release staged above — it
# compiles its own sidecar from the tag's source. Triggering it before
# the npm publishes overlaps its build with them; its release job waits
# for THIS run (by the id passed here) to succeed before promoting the
# release, so a failed npm publish still leaves the release in draft.
- name: Trigger desktop build
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG"
run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" -f executor_run_id="$GITHUB_RUN_ID"

- name: Publish executor to npm
run: |
export GITHUB_REF_TYPE=tag
export GITHUB_REF_NAME="$RELEASE_TAG"
export GITHUB_REF="refs/tags/$RELEASE_TAG"
bun run --cwd apps/cli src/release.ts --publish-only

- name: Trigger self-host Docker publish
env:
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ jobs:
# triggers publish-executor-package.yml. A second workflow_dispatch path
# races a duplicate publish for the same release.

# Desktop build downloads CLI binaries from the release, so it must
# run after CLI publish completes. Trigger it from the CLI workflow
# or manually via: gh workflow run publish-desktop.yml -f tag=vX.Y.Z
# Desktop compiles its own sidecar from the tag's source; it needs only
# the tag and the staged draft release, so publish-executor-package.yml
# triggers it as soon as those exist. Manual repair:
# gh workflow run publish-desktop.yml -f tag=vX.Y.Z
164 changes: 143 additions & 21 deletions apps/cli/src/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { fileURLToPath } from "node:url";

type ReleaseChannel = "latest" | "beta";

type ReleaseMode = "full" | "dry-run" | "stage-only" | "publish-only";

type ReleaseCliOptions = {
readonly dryRun: boolean;
readonly mode: ReleaseMode;
readonly skipBuild: boolean;
};

type CommandInput = {
Expand All @@ -31,18 +34,40 @@ const semverPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;

const parseArgs = (argv: ReadonlyArray<string>): ReleaseCliOptions => {
let dryRun = false;
let mode: ReleaseMode = "full";
let skipBuild = false;

for (const arg of argv) {
if (arg === "--dry-run") {
dryRun = true;
if (arg === "--skip-build") {
skipBuild = true;
continue;
}

throw new Error(`Unknown argument: ${arg}`);
const next: ReleaseMode | null =
arg === "--dry-run"
? "dry-run"
: arg === "--stage-only"
? "stage-only"
: arg === "--publish-only"
? "publish-only"
: null;
if (next === null) {
throw new Error(`Unknown argument: ${arg}`);
}
if (mode !== "full") {
throw new Error(`--${next} conflicts with --${mode}; pass at most one mode flag`);
}
mode = next;
}

return { dryRun };
if (mode === "publish-only" && skipBuild) {
throw new Error("--skip-build is implied by --publish-only; drop it");
}
if (mode === "dry-run" && skipBuild) {
throw new Error("--skip-build with --dry-run would validate artifacts and do nothing else");
}

return { mode, skipBuild };
};

const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
Expand Down Expand Up @@ -223,7 +248,7 @@ const syncGitHubRelease = async (input: {

// Draft until publish-desktop.yml finishes uploading installers and flips
// it; otherwise /releases/latest/download/<desktop-asset> 404s during the
// ~15-20 min desktop build window.
// desktop build window.
const args = [
"release",
"create",
Expand All @@ -249,6 +274,83 @@ const syncGitHubRelease = async (input: {
});
};

/** Locate the wrapper archive a previous build of this exact version left in
* dist/release. Used by --skip-build and --publish-only so a run that just
* built (release:check's dry-run, or a --stage-only step) is not repeated.
* Fails hard on any mismatch — reusing stale artifacts must never be a
* silent fallback. */
const locateBuiltArtifacts = async (version: string): Promise<string> => {
const wrapperPkgPath = join(wrapperDir, "package.json");
if (!existsSync(wrapperPkgPath)) {
throw new Error(`No built wrapper package at ${wrapperPkgPath}; run without --skip-build.`);
}

const wrapperPkg = (await Bun.file(wrapperPkgPath).json()) as {
version?: string;
optionalDependencies?: Record<string, string>;
};
if (wrapperPkg.version !== version) {
throw new Error(
`Built wrapper version ${wrapperPkg.version} does not match ${version}; run without --skip-build.`,
);
}

const expectedArchive = join(releaseDir, `executor-${version}.tgz`);
if (!existsSync(expectedArchive)) {
throw new Error(`Missing packed wrapper ${expectedArchive}; run without --skip-build.`);
}

// The wrapper's optionalDependencies are the source of truth for which
// platform variants this release ships. `build.ts publish` globs the
// dist/executor-*/ directories and publishes whatever it finds, so a
// missing dir would ship a wrapper referencing a variant that never
// reached npm, and an extra stale dir (say a leftover beta variant)
// would be published alongside. Require the exact set, each at the exact
// aliased version, each with its release archive present.
const optional = wrapperPkg.optionalDependencies ?? {};
const expectedVariants = Object.keys(optional).sort();
if (expectedVariants.length === 0) {
throw new Error(`Built wrapper has no optionalDependencies; run without --skip-build.`);
}

const variantDirs = [...new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })]
.map((entry) => dirname(entry))
.sort();
if (variantDirs.join(",") !== expectedVariants.join(",")) {
throw new Error(
`Platform variant dirs [${variantDirs.join(", ")}] do not match the wrapper's ` +
`optionalDependencies [${expectedVariants.join(", ")}]; run without --skip-build.`,
);
}

for (const name of expectedVariants) {
const spec = optional[name]!;
const aliasPrefix = "npm:executor@";
if (!spec.startsWith(aliasPrefix)) {
throw new Error(`Unexpected optionalDependency spec for ${name}: ${spec}`);
}
const aliasVersion = spec.slice(aliasPrefix.length);

const variantPkg = (await Bun.file(join(distDir, name, "package.json")).json()) as {
version?: string;
};
if (variantPkg.version !== aliasVersion) {
throw new Error(
`${name} version ${variantPkg.version} does not match the wrapper's ` +
`${aliasVersion}; run without --skip-build.`,
);
}

if (!existsSync(join(distDir, `${name}.tar.gz`)) && !existsSync(join(distDir, `${name}.zip`))) {
throw new Error(
`Missing release archive for ${name} in ${distDir}; run without --skip-build.`,
);
}
}

return expectedArchive;
};

const main = async () => {
const options = parseArgs(process.argv.slice(2));
const version = await readVersion();
Expand All @@ -262,30 +364,46 @@ const main = async () => {
throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`);
}

await rm(releaseDir, { recursive: true, force: true });
await mkdir(releaseDir, { recursive: true });
if (options.mode === "publish-only") {
await locateBuiltArtifacts(version);
await runCommand({
command: "bun",
args: ["run", "src/build.ts", "publish", channel],
cwd: cliRoot,
});
return;
}

await runCommand({
command: "bun",
args: ["run", "src/build.ts", "binary"],
cwd: cliRoot,
});
let wrapperArchivePath: string;
if (options.skipBuild) {
wrapperArchivePath = await locateBuiltArtifacts(version);
} else {
await rm(releaseDir, { recursive: true, force: true });
await mkdir(releaseDir, { recursive: true });

await runCommand({
command: "bun",
args: ["run", "src/build.ts", "release-assets"],
cwd: cliRoot,
});
await runCommand({
command: "bun",
args: ["run", "src/build.ts", "binary"],
cwd: cliRoot,
});

await runCommand({
command: "bun",
args: ["run", "src/build.ts", "release-assets"],
cwd: cliRoot,
});

wrapperArchivePath = await packWrapperPackage();
}

const wrapperArchivePath = await packWrapperPackage();
const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath);

console.log(`Prepared executor@${version} for ${channel}`);
for (const assetPath of assetPaths) {
console.log(`- ${assetPath}`);
}

if (options.dryRun) {
if (options.mode === "dry-run") {
return;
}

Expand All @@ -295,6 +413,10 @@ const main = async () => {
assetPaths,
});

if (options.mode === "stage-only") {
return;
}

await runCommand({
command: "bun",
args: ["run", "src/build.ts", "publish", channel],
Expand Down
Loading
Loading