Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
657 lines
26 KiB
YAML
657 lines
26 KiB
YAML
name: Publish Tutti App Release
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
app_id:
|
|
required: true
|
|
type: string
|
|
package_command:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
package_dir:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
icon_path:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
release_tag_prefix:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
release_bump:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
min_tutti_version:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
create_release_tag:
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
runner:
|
|
required: false
|
|
type: string
|
|
default: ubuntu-latest
|
|
node_version:
|
|
required: false
|
|
type: string
|
|
default: "24"
|
|
pnpm_version:
|
|
required: false
|
|
type: string
|
|
default: "10.11.0"
|
|
release_tools_package:
|
|
required: false
|
|
type: string
|
|
default: "@tutti-os/app-release-tools@latest"
|
|
aws_region:
|
|
required: true
|
|
type: string
|
|
aws_role_arn:
|
|
required: true
|
|
type: string
|
|
s3_bucket:
|
|
required: true
|
|
type: string
|
|
s3_prefix:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
release_assets_base_url:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
publish_catalog:
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
catalog_only:
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
prepare_only:
|
|
description: Build and upload an Actions release candidate without mutating cloud release state.
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
candidate_run_id:
|
|
description: Actions run containing an approved production release candidate.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
expected_version:
|
|
description: Approved candidate version.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
expected_artifact_sha256:
|
|
description: Approved candidate artifact SHA-256.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
expected_git_sha:
|
|
description: Approved candidate source commit.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
catalog_cloudfront_distribution_id:
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
|
|
jobs:
|
|
publish:
|
|
if: ${{ inputs.catalog_only || !contains(github.event.head_commit.message || '', '[skip release]') }}
|
|
runs-on: ${{ inputs.runner }}
|
|
concurrency:
|
|
group: ${{ (!inputs.prepare_only && (inputs.publish_catalog || inputs.catalog_only)) && format('tutti-app-catalog-{0}-{1}', inputs.s3_bucket, inputs.s3_prefix) || format('tutti-app-release-{0}-{1}', inputs.app_id, github.ref_name) }}
|
|
cancel-in-progress: false
|
|
|
|
steps:
|
|
- name: Checkout app repository
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: ${{ !inputs.prepare_only }}
|
|
|
|
- name: Validate release mode
|
|
shell: bash
|
|
env:
|
|
CATALOG_ONLY: ${{ inputs.catalog_only }}
|
|
PREPARE_ONLY: ${{ inputs.prepare_only }}
|
|
CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }}
|
|
EXPECTED_VERSION: ${{ inputs.expected_version }}
|
|
EXPECTED_ARTIFACT_SHA256: ${{ inputs.expected_artifact_sha256 }}
|
|
EXPECTED_GIT_SHA: ${{ inputs.expected_git_sha }}
|
|
run: |
|
|
if [ "${CATALOG_ONLY}" = "true" ] && [ "${PREPARE_ONLY}" = "true" ]; then
|
|
echo "catalog_only and prepare_only are mutually exclusive." >&2
|
|
exit 1
|
|
fi
|
|
candidate_values=(
|
|
"${CANDIDATE_RUN_ID}"
|
|
"${EXPECTED_VERSION}"
|
|
"${EXPECTED_ARTIFACT_SHA256}"
|
|
"${EXPECTED_GIT_SHA}"
|
|
)
|
|
candidate_value_count=0
|
|
for value in "${candidate_values[@]}"; do
|
|
if [ -n "${value}" ]; then
|
|
candidate_value_count=$((candidate_value_count + 1))
|
|
fi
|
|
done
|
|
if [ "${candidate_value_count}" -ne 0 ] && [ "${candidate_value_count}" -ne 4 ]; then
|
|
echo "candidate_run_id, expected_version, expected_artifact_sha256, and expected_git_sha must be supplied together." >&2
|
|
exit 1
|
|
fi
|
|
if [ "${PREPARE_ONLY}" = "true" ] && [ "${candidate_value_count}" -ne 0 ]; then
|
|
echo "prepare_only cannot promote an existing candidate." >&2
|
|
exit 1
|
|
fi
|
|
if [ "${CATALOG_ONLY}" = "true" ] && [ "${candidate_value_count}" -ne 0 ]; then
|
|
echo "catalog_only cannot promote a release candidate." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Validate release inputs
|
|
if: ${{ !inputs.catalog_only }}
|
|
shell: bash
|
|
env:
|
|
PACKAGE_COMMAND: ${{ inputs.package_command }}
|
|
PACKAGE_DIR: ${{ inputs.package_dir }}
|
|
RELEASE_ASSETS_BASE_URL: ${{ inputs.release_assets_base_url }}
|
|
RELEASE_BUMP: ${{ inputs.release_bump }}
|
|
CREATE_RELEASE_TAG: ${{ inputs.create_release_tag }}
|
|
PREPARE_ONLY: ${{ inputs.prepare_only }}
|
|
MIN_TUTTI_VERSION: ${{ inputs.min_tutti_version }}
|
|
run: |
|
|
if [ -z "${PACKAGE_COMMAND}" ]; then
|
|
echo "package_command is required unless catalog_only is true." >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "${PACKAGE_DIR}" ]; then
|
|
echo "package_dir is required unless catalog_only is true." >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "${RELEASE_ASSETS_BASE_URL}" ]; then
|
|
echo "release_assets_base_url is required unless catalog_only is true." >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "${MIN_TUTTI_VERSION}" ] && [[ ! "${MIN_TUTTI_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
|
|
echo "min_tutti_version must be valid SemVer." >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "${RELEASE_BUMP}" ] && [[ ! "${RELEASE_BUMP}" =~ ^(major|minor|patch)$ ]]; then
|
|
echo "release_bump must be one of major, minor, or patch when provided." >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "${RELEASE_BUMP}" ] && [ "${CREATE_RELEASE_TAG}" != "true" ] && [ "${PREPARE_ONLY}" != "true" ]; then
|
|
echo "release_bump requires create_release_tag." >&2
|
|
exit 1
|
|
fi
|
|
if [ "${CREATE_RELEASE_TAG}" = "true" ] && [ -z "${RELEASE_BUMP}" ]; then
|
|
echo "create_release_tag requires release_bump." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ inputs.node_version }}
|
|
cache: pnpm
|
|
|
|
- name: Download approved production release candidate
|
|
if: ${{ inputs.candidate_run_id != '' }}
|
|
uses: actions/download-artifact@v5
|
|
with:
|
|
name: ${{ format('{0}-{1}-production-candidate', inputs.app_id, inputs.expected_version) }}
|
|
path: tutti-app-release
|
|
github-token: ${{ github.token }}
|
|
run-id: ${{ inputs.candidate_run_id }}
|
|
|
|
- name: Install app dependencies
|
|
if: ${{ !inputs.catalog_only && inputs.candidate_run_id == '' }}
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Build app package
|
|
if: ${{ !inputs.catalog_only && inputs.candidate_run_id == '' }}
|
|
run: ${{ inputs.package_command }}
|
|
|
|
- name: Resolve release version
|
|
if: ${{ !inputs.catalog_only }}
|
|
id: release
|
|
shell: bash
|
|
env:
|
|
RELEASE_TAG_PREFIX: ${{ inputs.release_tag_prefix }}
|
|
RELEASE_BUMP: ${{ inputs.release_bump }}
|
|
CREATE_RELEASE_TAG: ${{ inputs.create_release_tag }}
|
|
APP_ID: ${{ inputs.app_id }}
|
|
PACKAGE_DIR: ${{ inputs.package_dir }}
|
|
CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }}
|
|
EXPECTED_VERSION: ${{ inputs.expected_version }}
|
|
EXPECTED_ARTIFACT_SHA256: ${{ inputs.expected_artifact_sha256 }}
|
|
EXPECTED_GIT_SHA: ${{ inputs.expected_git_sha }}
|
|
run: |
|
|
node <<'NODE'
|
|
const fs = require("fs");
|
|
const { execFileSync } = require("child_process");
|
|
|
|
const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
const stableSemverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
|
|
|
|
function fail(message) {
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|
|
|
|
function output(name, value) {
|
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
}
|
|
|
|
function defaultTagPrefix() {
|
|
return process.env.RELEASE_TAG_PREFIX || `${process.env.APP_ID}-v`;
|
|
}
|
|
|
|
function parseStableVersion(version, source) {
|
|
const match = version.match(stableSemverPattern);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return {
|
|
source,
|
|
version,
|
|
major: Number(match[1]),
|
|
minor: Number(match[2]),
|
|
patch: Number(match[3])
|
|
};
|
|
}
|
|
|
|
function compareVersions(left, right) {
|
|
for (const key of ["major", "minor", "patch"]) {
|
|
if (left[key] !== right[key]) {
|
|
return left[key] - right[key];
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function latestVersionSeed(prefix, manifestVersion, requireStableManifest) {
|
|
execFileSync("git", ["fetch", "--tags", "--force"], { stdio: "inherit" });
|
|
const tags = execFileSync("git", ["tag", "--list"], { encoding: "utf8" })
|
|
.split("\n")
|
|
.map((tag) => tag.trim())
|
|
.filter((tag) => tag.startsWith(prefix));
|
|
const stableManifestVersion = String(manifestVersion || "")
|
|
.split("+", 1)[0]
|
|
.split("-", 1)[0];
|
|
const manifestSeed = parseStableVersion(
|
|
stableManifestVersion,
|
|
"package manifest"
|
|
);
|
|
if (requireStableManifest && !manifestSeed) {
|
|
fail(`Package manifest version must be stable semver x.y.z when release_bump is used, got ${manifestVersion}.`);
|
|
}
|
|
const tagSeeds = tags
|
|
.map((tag) => parseStableVersion(tag.slice(prefix.length), `tag ${tag}`))
|
|
.filter(Boolean);
|
|
const seeds = [...(manifestSeed ? [manifestSeed] : []), ...tagSeeds];
|
|
const latest = seeds.sort(compareVersions).at(-1) ?? {
|
|
source: "default",
|
|
version: "0.0.0",
|
|
major: 0,
|
|
minor: 0,
|
|
patch: 0
|
|
};
|
|
console.log(`Resolved release version seed ${latest.version} from ${latest.source}.`);
|
|
return latest;
|
|
}
|
|
|
|
function nextVersionFromSources(prefix, bump, manifestVersion) {
|
|
const latest = latestVersionSeed(prefix, manifestVersion, true);
|
|
|
|
if (bump === "major") {
|
|
return `${latest.major + 1}.0.0`;
|
|
}
|
|
if (bump === "minor") {
|
|
return `${latest.major}.${latest.minor + 1}.0`;
|
|
}
|
|
if (bump === "patch") {
|
|
return `${latest.major}.${latest.minor}.${latest.patch + 1}`;
|
|
}
|
|
fail(`release_bump must be one of major, minor, or patch when provided, got ${bump}.`);
|
|
}
|
|
|
|
let gitSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
encoding: "utf8"
|
|
}).trim();
|
|
const releaseBump = process.env.RELEASE_BUMP;
|
|
const shouldCreateTag = process.env.CREATE_RELEASE_TAG === "true";
|
|
let releaseVersion = "";
|
|
let tagName = "";
|
|
|
|
if (process.env.CANDIDATE_RUN_ID) {
|
|
const expectedVersion = process.env.EXPECTED_VERSION;
|
|
const expectedArtifactSha256 = process.env.EXPECTED_ARTIFACT_SHA256;
|
|
const expectedGitSha = process.env.EXPECTED_GIT_SHA;
|
|
const releasePath = `tutti-app-release/apps/${process.env.APP_ID}/${expectedVersion}/release.json`;
|
|
const candidate = JSON.parse(fs.readFileSync(releasePath, "utf8"));
|
|
const mismatches = [];
|
|
if (candidate.appId !== process.env.APP_ID) mismatches.push("appId");
|
|
if (candidate.version !== expectedVersion) mismatches.push("version");
|
|
if (candidate.artifactSha256 !== expectedArtifactSha256) mismatches.push("artifactSha256");
|
|
if (candidate.gitSha !== expectedGitSha) mismatches.push("gitSha");
|
|
if (mismatches.length > 0) {
|
|
fail(`Approved release candidate mismatch: ${mismatches.join(", ")}`);
|
|
}
|
|
releaseVersion = expectedVersion;
|
|
gitSha = expectedGitSha;
|
|
tagName = `${defaultTagPrefix()}${releaseVersion}`;
|
|
} else {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(`${process.env.PACKAGE_DIR}/tutti.app.json`, "utf8")
|
|
);
|
|
if (releaseBump) {
|
|
const tagPrefix = defaultTagPrefix();
|
|
releaseVersion = nextVersionFromSources(
|
|
tagPrefix,
|
|
releaseBump,
|
|
manifest.version
|
|
);
|
|
tagName = `${tagPrefix}${releaseVersion}`;
|
|
} else {
|
|
if (shouldCreateTag) {
|
|
fail("create_release_tag requires release_bump.");
|
|
}
|
|
// Staging publishes do not bump. Seed from max(manifest, release tags)
|
|
// so staging stays on the same product line as production tags.
|
|
const tagPrefix = defaultTagPrefix();
|
|
const seed = latestVersionSeed(tagPrefix, manifest.version, false);
|
|
releaseVersion = `${seed.version}+${gitSha.slice(0, 12)}`;
|
|
}
|
|
}
|
|
|
|
if (!semverPattern.test(releaseVersion)) {
|
|
fail(`Release version must be semver x.y.z with optional prerelease/build metadata, got ${releaseVersion}.`);
|
|
}
|
|
|
|
output("version", releaseVersion);
|
|
output("git_sha", gitSha);
|
|
output("tag_name", tagName);
|
|
NODE
|
|
|
|
- name: Build release metadata
|
|
if: ${{ !inputs.catalog_only && inputs.candidate_run_id == '' }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
PACKAGE_DIR: ${{ inputs.package_dir }}
|
|
ICON_PATH: ${{ inputs.icon_path }}
|
|
RELEASE_TOOLS_PACKAGE: ${{ inputs.release_tools_package }}
|
|
RELEASE_ASSETS_BASE_URL: ${{ inputs.release_assets_base_url }}
|
|
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
|
run: |
|
|
args=(
|
|
--app-id "${APP_ID}"
|
|
--package-dir "${PACKAGE_DIR}"
|
|
--output-dir tutti-app-release
|
|
--base-url "${RELEASE_ASSETS_BASE_URL}"
|
|
--version "${RELEASE_VERSION}"
|
|
--git-sha "${{ steps.release.outputs.git_sha }}"
|
|
)
|
|
if [ -n "${ICON_PATH}" ]; then
|
|
args+=(--icon-path "${ICON_PATH}")
|
|
fi
|
|
pnpm --package "${RELEASE_TOOLS_PACKAGE}" dlx build-tutti-app-release "${args[@]}"
|
|
|
|
- name: Verify local app release candidate
|
|
if: ${{ !inputs.catalog_only }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
|
run: |
|
|
node <<'NODE'
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { createHash } = require("crypto");
|
|
|
|
const root = path.resolve("tutti-app-release");
|
|
const releaseDir = path.join(root, "apps", process.env.APP_ID, process.env.RELEASE_VERSION);
|
|
const versionedPath = path.join(releaseDir, "release.json");
|
|
const latestPath = path.join(root, "apps", process.env.APP_ID, "latest.json");
|
|
const release = JSON.parse(fs.readFileSync(versionedPath, "utf8"));
|
|
const latest = JSON.parse(fs.readFileSync(latestPath, "utf8"));
|
|
if (JSON.stringify(release) !== JSON.stringify(latest)) {
|
|
throw new Error("versioned release.json and latest.json must match exactly");
|
|
}
|
|
if (release.appId !== process.env.APP_ID || release.version !== process.env.RELEASE_VERSION) {
|
|
throw new Error("local release identity does not match the resolved app and version");
|
|
}
|
|
const artifactName = decodeURIComponent(path.basename(new URL(release.artifactUrl).pathname));
|
|
const expectedArtifactName = `${release.appId}-${release.version}.zip`;
|
|
if (artifactName !== expectedArtifactName) {
|
|
throw new Error(`local release artifact name mismatch: want ${expectedArtifactName} got ${artifactName}`);
|
|
}
|
|
const artifactPath = path.join(releaseDir, artifactName);
|
|
const data = fs.readFileSync(artifactPath);
|
|
const sha256 = createHash("sha256").update(data).digest("hex");
|
|
if (sha256 !== release.artifactSha256.toLowerCase()) {
|
|
throw new Error(`local release artifact sha256 mismatch: want ${release.artifactSha256} got ${sha256}`);
|
|
}
|
|
if (data.length !== release.artifactSizeBytes) {
|
|
throw new Error(`local release artifact size mismatch: want ${release.artifactSizeBytes} got ${data.length}`);
|
|
}
|
|
console.log(`Verified local release candidate ${release.appId}@${release.version} ${sha256}`);
|
|
NODE
|
|
|
|
- name: Preflight release tag
|
|
if: ${{ !inputs.catalog_only && !inputs.prepare_only && inputs.create_release_tag }}
|
|
shell: bash
|
|
env:
|
|
RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }}
|
|
RELEASE_GIT_SHA: ${{ steps.release.outputs.git_sha }}
|
|
run: |
|
|
git rev-parse --verify "${RELEASE_GIT_SHA}^{commit}" >/dev/null
|
|
git fetch --tags --force
|
|
if git rev-parse -q --verify "refs/tags/${RELEASE_TAG_NAME}" >/dev/null; then
|
|
existing_sha="$(git rev-list -n 1 "${RELEASE_TAG_NAME}")"
|
|
if [ "${existing_sha}" != "${RELEASE_GIT_SHA}" ]; then
|
|
echo "Release tag ${RELEASE_TAG_NAME} already points to ${existing_sha}, expected ${RELEASE_GIT_SHA}." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
- name: Upload production release candidate
|
|
if: ${{ inputs.prepare_only }}
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: ${{ format('{0}-{1}-production-candidate', inputs.app_id, steps.release.outputs.version) }}
|
|
path: tutti-app-release
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
- name: Configure AWS credentials
|
|
if: ${{ !inputs.prepare_only }}
|
|
uses: aws-actions/configure-aws-credentials@v4
|
|
with:
|
|
aws-region: ${{ inputs.aws_region }}
|
|
role-to-assume: ${{ inputs.aws_role_arn }}
|
|
|
|
- name: Upload app release to S3
|
|
if: ${{ !inputs.catalog_only && !inputs.prepare_only }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
|
S3_BUCKET: ${{ inputs.s3_bucket }}
|
|
S3_PREFIX: ${{ inputs.s3_prefix }}
|
|
run: |
|
|
local_release_file="tutti-app-release/apps/${APP_ID}/${RELEASE_VERSION}/release.json"
|
|
|
|
prefix="${S3_PREFIX%/}"
|
|
if [ -n "${prefix}" ]; then
|
|
s3_root="s3://${S3_BUCKET}/${prefix}"
|
|
release_key="${prefix}/apps/${APP_ID}/${RELEASE_VERSION}/release.json"
|
|
else
|
|
s3_root="s3://${S3_BUCKET}"
|
|
release_key="apps/${APP_ID}/${RELEASE_VERSION}/release.json"
|
|
fi
|
|
|
|
if aws s3api head-object --bucket "${S3_BUCKET}" --key "${release_key}" >/dev/null 2>tutti-app-release-head-error.txt; then
|
|
mkdir -p tutti-app-release/existing
|
|
aws s3 cp "${s3_root}/apps/${APP_ID}/${RELEASE_VERSION}/release.json" tutti-app-release/existing/release.json
|
|
node -e '
|
|
const fs = require("fs");
|
|
const localRelease = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
const existingRelease = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
const comparedKeys = [
|
|
"appId",
|
|
"version",
|
|
"artifactUrl",
|
|
"artifactSha256",
|
|
"artifactSizeBytes",
|
|
"iconUrl",
|
|
"gitSha"
|
|
];
|
|
const mismatches = comparedKeys.filter(
|
|
(key) => localRelease[key] !== existingRelease[key]
|
|
);
|
|
if (mismatches.length > 0) {
|
|
console.error(
|
|
`Release ${localRelease.version} already exists for ${localRelease.appId} with different immutable metadata: ${mismatches.join(", ")}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
' "${local_release_file}" tutti-app-release/existing/release.json
|
|
echo "Release ${RELEASE_VERSION} already exists for ${APP_ID} with matching immutable metadata. Repairing mutable latest/catalog state."
|
|
elif grep -Eq "(404|Not Found|NotFound|NoSuchKey)" tutti-app-release-head-error.txt; then
|
|
aws s3 sync "tutti-app-release/apps/${APP_ID}/${RELEASE_VERSION}/" "${s3_root}/apps/${APP_ID}/${RELEASE_VERSION}/" \
|
|
--cache-control "public, max-age=31536000, immutable"
|
|
else
|
|
echo "Failed to check existing release ${release_key}." >&2
|
|
cat tutti-app-release-head-error.txt >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify published app release artifact
|
|
if: ${{ !inputs.catalog_only && !inputs.prepare_only }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
RELEASE_TOOLS_PACKAGE: ${{ inputs.release_tools_package }}
|
|
run: |
|
|
pnpm --package "${RELEASE_TOOLS_PACKAGE}" dlx verify-tutti-app-release-artifacts \
|
|
--release-file "tutti-app-release/apps/${APP_ID}/latest.json"
|
|
|
|
- name: Publish app release metadata
|
|
if: ${{ !inputs.prepare_only }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
CATALOG_ONLY: ${{ inputs.catalog_only }}
|
|
MIN_TUTTI_VERSION: ${{ inputs.min_tutti_version }}
|
|
PUBLISH_CATALOG: ${{ inputs.publish_catalog }}
|
|
RELEASE_TOOLS_PACKAGE: ${{ inputs.release_tools_package }}
|
|
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
|
S3_BUCKET: ${{ inputs.s3_bucket }}
|
|
S3_PREFIX: ${{ inputs.s3_prefix }}
|
|
run: |
|
|
args=(
|
|
--app-id "${APP_ID}"
|
|
--s3-bucket "${S3_BUCKET}"
|
|
--s3-prefix "${S3_PREFIX}"
|
|
--output-dir tutti-app-metadata
|
|
)
|
|
if [ -n "${MIN_TUTTI_VERSION}" ]; then
|
|
args+=(--min-tutti-version "${MIN_TUTTI_VERSION}")
|
|
fi
|
|
if [ "${CATALOG_ONLY}" = "true" ]; then
|
|
args+=(--catalog-only --publish-catalog)
|
|
else
|
|
args+=(--release-file "tutti-app-release/apps/${APP_ID}/${RELEASE_VERSION}/release.json")
|
|
if [ "${PUBLISH_CATALOG}" = "true" ]; then
|
|
args+=(--publish-catalog)
|
|
fi
|
|
fi
|
|
pnpm --package "${RELEASE_TOOLS_PACKAGE}" dlx publish-tutti-app-metadata "${args[@]}"
|
|
|
|
- name: Verify published app metadata
|
|
if: ${{ !inputs.prepare_only }}
|
|
shell: bash
|
|
env:
|
|
CATALOG_ONLY: ${{ inputs.catalog_only }}
|
|
PUBLISH_CATALOG: ${{ inputs.publish_catalog }}
|
|
RELEASE_TOOLS_PACKAGE: ${{ inputs.release_tools_package }}
|
|
run: |
|
|
args=(--versions-file tutti-app-metadata/versions.json)
|
|
if [ "${CATALOG_ONLY}" != "true" ]; then
|
|
args+=(--release-file tutti-app-metadata/latest.json)
|
|
fi
|
|
if [ "${CATALOG_ONLY}" = "true" ] || [ "${PUBLISH_CATALOG}" = "true" ]; then
|
|
args+=(--catalog-file tutti-app-metadata/catalog.json)
|
|
fi
|
|
pnpm --package "${RELEASE_TOOLS_PACKAGE}" dlx verify-tutti-app-release-artifacts "${args[@]}"
|
|
|
|
- name: Create release tag
|
|
if: ${{ !inputs.catalog_only && !inputs.prepare_only && inputs.create_release_tag }}
|
|
shell: bash
|
|
env:
|
|
APP_ID: ${{ inputs.app_id }}
|
|
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
|
RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }}
|
|
RELEASE_GIT_SHA: ${{ steps.release.outputs.git_sha }}
|
|
run: |
|
|
if [ -z "${RELEASE_TAG_NAME}" ]; then
|
|
echo "Resolved release tag name is empty." >&2
|
|
exit 1
|
|
fi
|
|
if ! git check-ref-format "refs/tags/${RELEASE_TAG_NAME}"; then
|
|
echo "Resolved release tag ${RELEASE_TAG_NAME} is not a valid git ref." >&2
|
|
exit 1
|
|
fi
|
|
|
|
git fetch --tags --force
|
|
if git rev-parse -q --verify "refs/tags/${RELEASE_TAG_NAME}" >/dev/null; then
|
|
existing_sha="$(git rev-list -n 1 "${RELEASE_TAG_NAME}")"
|
|
if [ "${existing_sha}" != "${RELEASE_GIT_SHA}" ]; then
|
|
echo "Release tag ${RELEASE_TAG_NAME} already points to ${existing_sha}, expected ${RELEASE_GIT_SHA}." >&2
|
|
exit 1
|
|
fi
|
|
echo "Release tag ${RELEASE_TAG_NAME} already exists for ${APP_ID} ${RELEASE_VERSION}."
|
|
exit 0
|
|
fi
|
|
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git tag -a "${RELEASE_TAG_NAME}" "${RELEASE_GIT_SHA}" -m "Release ${APP_ID} ${RELEASE_VERSION}"
|
|
git push origin "refs/tags/${RELEASE_TAG_NAME}"
|
|
|
|
- name: Invalidate app catalog
|
|
if: ${{ !inputs.prepare_only && (inputs.publish_catalog || inputs.catalog_only) && inputs.catalog_cloudfront_distribution_id != '' }}
|
|
shell: bash
|
|
env:
|
|
CATALOG_CLOUDFRONT_DISTRIBUTION_ID: ${{ inputs.catalog_cloudfront_distribution_id }}
|
|
S3_PREFIX: ${{ inputs.s3_prefix }}
|
|
run: |
|
|
prefix="${S3_PREFIX%/}"
|
|
if [ -n "${prefix}" ]; then
|
|
path_to_invalidate="/${prefix}/catalog.json"
|
|
else
|
|
path_to_invalidate="/catalog.json"
|
|
fi
|
|
aws cloudfront create-invalidation \
|
|
--distribution-id "${CATALOG_CLOUDFRONT_DISTRIBUTION_ID}" \
|
|
--paths "${path_to_invalidate}"
|