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
59 changes: 53 additions & 6 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ jobs:
run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; }

- name: Build CLI archive
id: build_cli
if: inputs.cli_archive
shell: bash
env:
Expand All @@ -425,25 +426,38 @@ jobs:
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
run: |
set -euo pipefail
if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then
key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8"
printf '%s' "$APPLE_API_KEY" > "$key_path"
export APPLE_API_KEY="$key_path"
extra_args=()
if [[ "${{ inputs.platform }}" == "mac" ]]; then
if [[ -n "${APPLE_API_KEY:-}" ]]; then
key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")"
trap 'rm -rf "$key_dir"' EXIT
key_path="$key_dir/AuthKey.p8"
(umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path")
export APPLE_API_KEY="$key_path"
fi
# Older release refs lack deferred notarization and still wait here.
cli_help="$(node scripts/build-cli-archive.ts --help)"
if [[ "$cli_help" == *--notary-submission-file* ]]; then
extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission")
echo "deferred_notarization=true" >> "$GITHUB_OUTPUT"
fi
fi
node scripts/build-cli-archive.ts \
--platform "${{ inputs.platform }}" \
--arch "${{ inputs.arch }}" \
--version "${{ inputs.version }}" \
--resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \
--output-dir release-cli
--output-dir release-cli \
${extra_args[@]+"${extra_args[@]}"}

- name: Smoke-test CLI archive
if: inputs.cli_archive && inputs.verify_runner == ''
shell: bash
run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}"

# macOS uploads its archive after "Await CLI notarization" instead.
- name: Upload CLI archive
if: inputs.cli_archive
if: inputs.cli_archive && inputs.platform != 'mac'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: cli-${{ inputs.platform }}-${{ inputs.arch }}
Expand Down Expand Up @@ -618,6 +632,39 @@ jobs:

vp run dist:desktop:artifact "${args[@]}"

# The CLI was submitted without waiting so Apple reviews it while the
# desktop builds. Notarization does not change the archive bytes, but
# nothing uploads them, so nothing publishes them, until Apple accepts.
# With signing configured, a missing submission id fails this step.
- name: Await CLI notarization
if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true'
shell: bash
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
if [[ -n "${APPLE_API_KEY:-}" ]]; then
key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")"
trap 'rm -rf "$key_dir"' EXIT
key_path="$key_dir/AuthKey.p8"
(umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path")
export APPLE_API_KEY="$key_path"
fi
node scripts/build-cli-archive.ts await-notarization \
--submission-file "$RUNNER_TEMP/t3-cli-notary-submission"

- name: Upload notarized CLI archive
if: inputs.cli_archive && inputs.platform == 'mac'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: cli-${{ inputs.platform }}-${{ inputs.arch }}
path: release-cli/*
if-no-files-found: error
# The archive is already gzip or zip; deflating it again saves under 2%.
compression-level: 0

# Warm installs only touch the store index, so new content means a fetch.
- name: Check staged install cache for new content
id: stage_pnpm_changed
Expand Down
216 changes: 216 additions & 0 deletions scripts/build-cli-archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import { awaitCliNotarization, signMacArchiveContents } from "./build-cli-archive.ts";

const signedEnv = {
T3CODE_CLI_MAC_SIGN_IDENTITY: "Developer ID Application: Test (TEAM)",
APPLE_API_KEY: "/tmp/AuthKey_TEST.p8",
APPLE_API_KEY_ID: "TEST",
APPLE_API_ISSUER: "issuer",
};

type NotaryReply = { readonly exitCode: number; readonly stdout?: string };

/** Stands in for `xcrun notarytool`, codesign and ditto; records each invocation. */
function stubSpawner(notary: (subcommand: string) => NotaryReply) {
const calls: Array<ReadonlyArray<string>> = [];
const spawner = ChildProcessSpawner.make((command) => {
const argv = ChildProcess.isStandardCommand(command)
? [command.command, ...command.args.map(String)]
: [];
calls.push(argv);
const reply = argv[1] === "notarytool" ? notary(argv[2] ?? "") : { exitCode: 0 };
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(reply.exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: reply.stdout ? Stream.make(new TextEncoder().encode(reply.stdout)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
);
});
return { calls, spawner };
}

const withStubs = <A, E, R>(
effect: Effect.Effect<A, E, R>,
stub: ReturnType<typeof stubSpawner>,
env: Record<string, string> = signedEnv,
) =>
effect.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, stub.spawner),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))),
);

const makeContent = Effect.fn("test.makeContent")(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-notary-" });
const contentDir = path.join(root, "content");
yield* fs.makeDirectory(contentDir);
const executablePath = path.join(contentDir, "t3");
yield* fs.writeFileString(executablePath, "t3");
return { root, contentDir, executablePath, submissionFile: path.join(root, "submission") };
});

const notarySubcommands = (calls: ReadonlyArray<ReadonlyArray<string>>) =>
calls.filter((argv) => argv[1] === "notarytool").map((argv) => argv.slice(2));

it.layer(NodeServices.layer)("build-cli-archive notarization", (it) => {
it.effect("records a deferred submission without waiting, then gates on Accepted", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
// Apple documents id and path for an unwaited submit; a pending status must not fail it.
for (const submitted of [
'{"id":"new-id","message":"Successfully uploaded file","path":"t3.zip"}',
'{"id":"new-id","status":"In Progress"}',
]) {
const content = yield* makeContent();
yield* fs.writeFileString(content.submissionFile, "stale-id");
const stub = stubSpawner((subcommand) =>
subcommand === "submit"
? { exitCode: 0, stdout: submitted }
: { exitCode: 0, stdout: '{"id":"new-id","status":"Accepted"}' },
);

yield* withStubs(
signMacArchiveContents({
repoRoot: content.root,
contentDir: content.contentDir,
executablePath: content.executablePath,
notarySubmissionFile: Option.some(content.submissionFile),
}),
stub,
);
assert.equal(yield* fs.readFileString(content.submissionFile), "new-id");
const [submit] = notarySubcommands(stub.calls);
assert.equal(submit?.[0], "submit");
assert.isFalse(submit?.includes("--wait"));

yield* withStubs(awaitCliNotarization(content.submissionFile), stub);
assert.deepEqual(notarySubcommands(stub.calls)[1]?.slice(0, 2), ["wait", "new-id"]);
}
}).pipe(Effect.scoped),
);

it.effect("fails the gate unless Apple accepted the submission", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const cases: ReadonlyArray<{
readonly name: string;
readonly wait: NotaryReply;
readonly submitted?: boolean;
readonly env?: Record<string, string>;
readonly error: string | undefined;
readonly fetchesLog?: boolean;
}> = [
{
name: "invalid",
wait: { exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' },
error: "CliArchiveNotarizationRejectedError",
fetchesLog: true,
},
{
name: "rejected with a non-zero exit",
wait: { exitCode: 1, stdout: '{"id":"sub","status":"Rejected"}' },
error: "CliArchiveNotarizationRejectedError",
fetchesLog: true,
},
{
name: "still in progress",
wait: { exitCode: 0, stdout: '{"id":"sub","status":"In Progress"}' },
error: "CliArchiveNotarizationRejectedError",
},
{
name: "still in progress with a non-zero exit",
wait: { exitCode: 1, stdout: '{"id":"sub","status":"In Progress"}' },
error: "CliArchiveCommandFailedError",
},
{
name: "notarytool failed",
wait: { exitCode: 69 },
error: "CliArchiveCommandFailedError",
},
{
name: "no status",
wait: { exitCode: 0, stdout: '{"id":"sub"}' },
error: "CliArchiveNotarizationRejectedError",
},
{
name: "accepted with a non-zero exit",
wait: { exitCode: 1, stdout: '{"id":"sub","status":"Accepted"}' },
error: "CliArchiveCommandFailedError",
},
{
name: "submission missing",
wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' },
submitted: false,
error: "CliArchiveInputMissingError",
},
{
name: "submission without credentials",
wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' },
env: { T3CODE_CLI_MAC_SIGN_IDENTITY: signedEnv.T3CODE_CLI_MAC_SIGN_IDENTITY },
error: "CliArchiveInputMissingError",
},
{
name: "notarization not configured",
wait: { exitCode: 0 },
submitted: false,
env: {},
error: undefined,
},
];
for (const testCase of cases) {
const content = yield* makeContent();
if (testCase.submitted !== false) yield* fs.writeFileString(content.submissionFile, "sub");
const stub = stubSpawner(() => testCase.wait);
const exit = yield* withStubs(
awaitCliNotarization(content.submissionFile),
stub,
testCase.env,
).pipe(Effect.flip, Effect.option);
assert.equal(Option.getOrUndefined(exit)?._tag, testCase.error, testCase.name);
assert.equal(
notarySubcommands(stub.calls).some((argv) => argv[0] === "log"),
testCase.fetchesLog ?? false,
testCase.name,
);
}
}).pipe(Effect.scoped),
);

it.effect("fails a standalone build whose waited submission is not Accepted", () =>
Effect.gen(function* () {
const content = yield* makeContent();
const stub = stubSpawner(() => ({ exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' }));
const error = yield* withStubs(
signMacArchiveContents({
repoRoot: content.root,
contentDir: content.contentDir,
executablePath: content.executablePath,
notarySubmissionFile: Option.none(),
}),
stub,
).pipe(Effect.flip);
assert.equal(error._tag, "CliArchiveNotarizationRejectedError");
assert.isTrue(notarySubcommands(stub.calls)[0]?.includes("--wait"));
}).pipe(Effect.scoped),
);
});
Loading
Loading